CLI to automate Substack post management: draft, schedule, publish, list, sections, batch operations + scan/crawl other newsletters (fetch all posts via archive API). Uses python-substack (reverse-engineered) + public JSON API (/api/v1/).
Initial setup
Dependencies
test -d ~/.venv/claude || uv venv ~/.venv/claude
uv pip install --python ~/.venv/claude/bin/python python-substack feedparser httpx beautifulsoup4 markdownify
Configure credentials
The script needs 2 variables: SUBSTACK_COOKIE and SUBSTACK_PUBLICATION_URL. Lookup order: env var → .env in the skill directory → .env in CWD.
| How | When |
|---|---|
Env var (~/.zshrc) |
Use frequently — set once, use forever |
File .env |
Multiple publications — one per directory .env (needs python-dotenv) |
Get cookie: Substack dashboard (logged in) → F12 → Application → Cookies → copy substack.sid. Expires after 1-2 weeks.
First-time setup details → setup-guide.
Commands
PY=~/.venv/claude/bin/python
SCRIPT=<skill-path>/scripts/substack_cli.py
draft — Create draft
$PY $SCRIPT draft ARTICLE.md COVER.png [--section "Tên section"] [--subtitle "..."]
ARTICLE.md: line 1 = title (auto strip#), line 2+ = body markdownCOVER.png: upload to Substack S3, insert at the top of the post as cover + inline image
schedule — Create draft + schedule
$PY $SCRIPT schedule ARTICLE.md COVER.png --at 2026-04-20T09:00:00 [--audience everyone]
| Flag | Default | Value |
|---|---|---|
--at |
required | ISO 8601. No timezone → use local. With timezone → use as is |
--audience |
everyone |
everyone / only_paid / only_free / founding |
publish — Create draft + publish immediately
$PY $SCRIPT publish ARTICLE.md COVER.png [--no-send]
WARNING: not reversible. The post goes public immediately, the email sent to subscribers cannot be recalled. Always ask the user for confirmation before running publish.
publish-existing — Publish an existing draft
$PY $SCRIPT publish-existing DRAFT_ID [--no-send]
unschedule — Cancel schedule, revert to draft
$PY $SCRIPT unschedule DRAFT_ID
list — List posts
$PY $SCRIPT list [--filter draft|scheduled|published|all] [--limit 25]
sections — List newsletter sections
$PY $SCRIPT sections
set-section — Assign a section to multiple drafts
$PY $SCRIPT set-section "Tên Section" DRAFT_ID1 DRAFT_ID2 ...
Scan / Crawl other newsletters (read-only, no auth needed)
scan — Scan the post list
$PY $SCRIPT scan <slug> [--limit 10] [--json] [--all]
slug: Substack slug (e.g.:platformer), full URL, or domain- Display a table: date, author, title
--json: output JSON instead of a table--all: fetch ALL posts via archive API (by default uses RSS, max ~25 posts)
crawl — Download 1 post as .md
$PY $SCRIPT crawl <URL> [--output-dir ./crawled]
- Download the full post, convert HTML → Markdown, save with YAML frontmatter
- Skip if the file already exists (idempotent)
crawl-feed — Batch download N posts from the feed
$PY $SCRIPT crawl-feed <slug> [--limit 5] [--output-dir ./crawled] [--all]
- Default: download the N latest posts from RSS
--all: download ALL posts via archive API (ignores --limit)- Automatic random 2-5s delay between each post (avoid rate limit)
Troubleshooting details → crawl-guide.
Common flags
--dry-run: print payload, do not call API. Place AFTER the subcommand name.--section "...": assign post to section (applies to draft/schedule/publish).--subtitle "...": subtitle displayed below the title.
Action boundaries
| Do it yourself | Must ask the user first |
|---|---|
| draft, schedule, list, sections, set-section, unschedule | publish / publish-existing — sends a real email, cannot be recalled |
| scan, crawl, crawl-feed (read-only) | Delete a published post |
--dry-run to check the payload |
Do not commit .env — contains auth cookies.
Reference documentation
references/setup-guide.md— details on configuring credentials for the first timereferences/api-quirks.md— endpoint quirks, rate limit, troubleshootingreferences/batch-operations.md— delay + retry pattern for batch schedulereferences/crawl-guide.md— troubleshooting scan/crawl: selectors, rate limit, paywall