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 — 27 tools, 7 resources, 2 prompts for AI agents
- ✅ Creator OS — Campaign plans, media/live planning, growth snapshots, and community triage
- ✅ 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
substack-cli draft create post.md --run-log-dir catalogue/run-log
# Inspect or schedule an existing draft by ID
substack-cli draft inspect --draft-id 123
substack-cli draft schedule --draft-id 123 --scheduled-at "2026-06-01T09:00:00Z" --run-log-dir catalogue/run-log
# 4. Publish
substack-cli publish post.md --yes
# 5. Schedule
substack-cli schedule post.md --at "2026-06-01T09:00:00Z" --yes
substack-cli schedule reconcile --schedule-file schedule.json --by title,time
substack-cli batch schedule --schedule-file schedule.json --draft-ids-file draft-ids.txt --dry-run- 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 preflight examples\basic.md --strict
node dist\cli.js preflight examples\basic.md --mode schedule --at 2026-05-01T09:00:00Z --schedule-file schedule.json --draft-id 123
node dist\cli.js draft examples\basic.md --dry-run
node dist\cli.js draft examples\basic.md --transport auto
node dist\cli.js draft create examples\basic.md --dry-run
node dist\cli.js draft inspect --draft-id 123
node dist\cli.js draft schedule --draft-id 123 --scheduled-at 2026-05-01T09:00:00Z --dry-run
node dist\cli.js note create --text-file examples\note.md --dry-run
node dist\cli.js note schedule --text-file examples\note.md --post-url https://example.substack.com/p/post --scheduled-at 2026-05-01T09:00:00Z --dry-run
node dist\cli.js note inspect --note-id 123
node dist\cli.js note batch --schedule-file notes.json --dry-run
node dist\cli.js campaign plan examples\basic.md --publish-at 2026-05-01T09:00:00Z --note-at 2026-05-01T10:00:00Z --channels notes,linkedin --out campaign.json
node dist\cli.js campaign validate --plan campaign.json
node dist\cli.js campaign execute --plan campaign.json --yes
node dist\cli.js campaign report --run-log-dir .substack-cli\run-log
node dist\cli.js media video plan --file video.mp4 --post examples\basic.md
node dist\cli.js media audio plan --file episode.mp3 --post examples\basic.md
node dist\cli.js live plan --title "Launch Q&A" --at 2026-05-01T11:00:00Z --audience paid
node dist\cli.js analytics snapshot --post-url https://example.substack.com/p/post --out snapshot.json --dry-run
node dist\cli.js analytics trend --snapshots-dir .substack-cli\analytics
node dist\cli.js growth report --campaign campaign.json
node dist\cli.js warehouse export --campaign campaign.json --analytics-dir .substack-cli\analytics --run-log-dir .substack-cli\run-log --out-dir .substack-cli\warehouse
node dist\cli.js warehouse attribution --campaign campaign.json --analytics-dir .substack-cli\analytics
node dist\cli.js backup plan --snapshot .substack-cli\backup\snapshot.json --source .substack-cli\warehouse
node dist\cli.js recommendations inspect
node dist\cli.js boost inspect
node dist\cli.js comments triage --post-id 123
node dist\cli.js notes campaign --post-url https://example.substack.com/p/post --schedule-file notes.json
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 coverage safe-surfaces
node dist\cli.js coverage safe-surface --id native-video-live-automation
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 --run-log-dir .substack-cli\run-log
node dist\cli.js schedule examples\basic.md --at 2026-05-01T09:00:00Z --yes --run-log-dir .substack-cli\run-log
node dist\cli.js schedule examples\basic.md --at 2026-05-01T09:00:00Z --transport auto --yes
node dist\cli.js schedule examples\basic.md --at 2026-05-01T09:00:00Z --schedule-file schedule.json --yes
node dist\cli.js schedule reconcile --schedule-file schedule.json --by title,time
node dist\cli.js batch schedule --schedule-file schedule.json --draft-ids-file draft-ids.txt --dry-run
node dist\cli.js batch schedule --schedule-file schedule.json --draft-ids-file draft-ids.txt --yes --run-log-dir .substack-cli\run-log
node dist\cli.js note create --text-file examples\note.md --yes --run-log-dir .substack-cli\run-log
node dist\cli.js note schedule --text-file examples\note.md --post-url https://example.substack.com/p/post --scheduled-at 2026-05-01T09:00:00Z --yes --run-log-dir .substack-cli\run-log
node dist\cli.js note batch --schedule-file notes.json --yes --run-log-dir .substack-cli\run-log
node dist\cli.js campaign plan examples\basic.md --channels notes,x,youtube --out campaign.json
node dist\cli.js media video plan --file launch.mp4 --post examples\basic.md
node dist\cli.js live plan --title "Reader Q&A" --at 2026-05-01T11:00:00Z --audience subscribers
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 prepublish and preflight validation first and stop early if the payload or operational contract is not compatible. Use --trace-out to capture a local JSON review artifact for later comparison. preflight checks the publication target, title, slug format, section/tag/cover readiness, editorial placeholders, schedule parse/future status, optional schedule-file collisions, and dry-run payload printability; --strict escalates optional workflow checks to blockers.
Scheduled notes enforce the covering-note contract before live writes: each item must include the matching postUrl, the note text must mention that URL, the note must be no more than three sentences, and scheduledAt must be a valid timestamp. A note batch file can be a JSON array or { "items": [...] }:
{
"items": [
{
"textFile": "notes/example.md",
"postUrl": "https://example.substack.com/p/post",
"scheduledAt": "2026-05-01T09:00:00Z",
"title": "Example post"
}
]
}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.