Documentation

Substack CLI

substack-pp-cli

Install

npx -y @mvanhorn/printing-press-library install substack

Installs the CLI binary and the agent skill for Claude Code, Codex, Cursor, Gemini CLI, Copilot, and more. Add --cli-only or --skill-only for one half. Requires Node.

Documentation

Run your Substack growth and authoring loop from the command line — publish rich drafts, manage a multi-publication portfolio, and measure what drives growth.

Substack has no public API and the closed-source tools that work around it (WriteStack, StackSweller) stop at Notes scheduling and a heatmap. This CLI covers the read endpoints the community has reverse-engineered across the 8 wrappers we studied, plus rich authoring (30+ flags on drafts create/update, Markdown→ProseMirror conversion), a multi-publication portfolio layer (portfolio syncportfolio, posts best, grep, schedule board, subs churn, subs cross-sell), and local-SQLite analytics. Every command is MCP-callable so an agent can drive the full publish → engage → measure → swap loop.

Created by @chirantan (Chirantan Rajhans). Contributors: @JPresting (JimPresting), @tmchow (Trevin Chow).

Authentication

Substack uses a session cookie (substack.sid). The only path today is auth login --chrome (also accepts --browser as an alias) — it reads the cookie from your logged-in Chrome via pycookiecheat / cookies / cookie-scoop-cli and stores it in the OS keyring. There is no password login and no manual cookie-paste subcommand. If your cookie expires, re-run auth login --chrome.

Quick Start

# Health check — confirms your stored Substack session is valid. Capture it first with `substack-pp-cli auth login --chrome`.
substack-pp-cli doctor --dry-run

# Probes all three Substack bases plus the RSS path to surface auth or Cloudflare issues early.
substack-pp-cli doctor

# Pulls posts, drafts, your Notes, comments, profiles, and subscriber-count snapshots into the local store.
substack-pp-cli sync --since 30d

# Bootstrap the portfolio analytics store (run once after login).
export SUBSTACK_PUBLICATION=mypub
substack-pp-cli portfolio sync --json

# One-screen status of every publication you own.
substack-pp-cli portfolio --json

# Dry-run prints the request without firing; drop --dry-run to publish.
substack-pp-cli notes new --body "Stop refreshing the feed. Spend 15 minutes in your inbox replying to commenters and you'll outgrow 90% of writers who don't." --dry-run

# Create a rich draft with Markdown body, paid audience, SEO metadata, and cover image.
# (SUBSTACK_PUBLICATION must already be set)
substack-pp-cli drafts create \
  --title "Why X matters" --body-file ./post.md \
  --audience only_paid --seo-title "X explained" \
  --cover-image https://substackcdn.com/.../cover.jpg --json

# Surfaces which of your last 30 days of Notes brought subs.
substack-pp-cli growth attribution --days 30 --agent --select rank,note_excerpt,subs_acquired

# Ranks candidate publications for a recommendation swap by audience overlap.
substack-pp-cli recs find-partners --my-pub on --top 10 --json

Unique Features

These capabilities aren't available in any other tool for this API.

Local state that compounds

  • growth attribution — Connect every Note you posted to the paid and free subscribers that actually arrived in the 24-hour window after, so you stop guessing which content drove growth.

    Pick this over a generic stats call when an agent needs to decide which Note formats to repeat next week.

    substack-pp-cli growth attribution --days 30 --json --select rank,note_id,note_excerpt,subs_acquired,paid_subs_acquired
    
  • engage reciprocity — See net-give/net-take per writer you engage with — who reciprocates your restacks/comments, who quietly free-rides on yours.

    Use when an agent is deciding whether to keep investing in a swap partner; surfaces relationships before they go stale.

    substack-pp-cli engage reciprocity --days 30 --agent --select handle,outgoing,incoming,net,drift
    

Algorithm-aware automation

  • notes schedule --guard — Refuse to fire (or queue) a Note that lands less than 30 minutes after your last own-Note or violates your time-of-day rotation. Returns typed exit 2 with a JSON diagnosis.

    Stops an agent from accidentally torching its own reach by dumping a queue all at once.

    substack-pp-cli notes schedule --at 2026-05-10T13:00:00Z --body "hook line\n\nbody" --guard --json
    
  • growth best-time — Top day-of-week × hour cells ranked for whichever growth signal you pick (paid subs, likes, restacks, or comments) — not a single average.

    An agent picking when to schedule tomorrow's Notes can ask for the goal it's optimizing instead of guessing.

    substack-pp-cli growth best-time --days 90 --for-goal subs --json --select day_of_week,hour,rate,sample_size
    

Pattern intelligence

  • discover patterns — Mechanically extracts which hook patterns (curiosity-gap colon, 3-sentence formula, em-dash reframe, question opener) actually rank in a niche, with restack/comment ratios.

    An agent drafting Notes can ask which hook shape currently outperforms in this niche before generating.

    substack-pp-cli discover patterns --niche productivity --sort restacks --since 14d --agent --select pattern,sample_count,avg_restacks,avg_comments,top_example
    
  • voice fingerprint — Measurable voice profile — sentence length, em-dash rate, colon-hook rate, hook-line ratios, vocabulary uniqueness — for any handle, with --diff to compare against another writer.

    An agent drafting Notes for a ghostwriter client can verify the output stays inside the client's voice envelope.

    substack-pp-cli voice fingerprint --handle maya --diff devon --json --select metric,self,other,delta
    

Network leverage

  • recs find-partners — Score candidate publications for a Substack Recommendations swap by mutual-overlap density across followee + recommendation graphs.

    An agent running a weekly cross-promo pass can rank candidates instead of pitching cold.

    substack-pp-cli recs find-partners --my-pub on --top 20 --json --select rank,handle,pub,overlap_score,shared_followees
    
  • growth pod — Given a list of handles, render a member × member engagement matrix — last 30 days of restacks/comments/likes between every pair.

    An agent organizing a mutual-aid pod can see who's net-positive vs free-riding without a spreadsheet.

    substack-pp-cli growth pod --members maya,devon,priya,jordan --days 30 --json
    

Authoring with rich field control

  • drafts create / drafts update — Full Substack draft API surface: 30+ flags covering title, subtitle, body (Markdown auto-converts to ProseMirror), section-id, post type (newsletter/podcast/video/thread), audience, bylines, SEO metadata, social title, cover image, comment settings, podcast/video URLs, and visibility toggles. The only authoring path that gives agents field-level control without fighting a web editor.

    Use when an agent is constructing a complete long-form post from structured data — research summary, translated copy, ghostwritten piece — and needs paywall, SEO, and section placement set in one command.

    # Markdown body, paid-only, with SEO + cover image
    # (SUBSTACK_PUBLICATION must already be set to the target publication subdomain)
    substack-pp-cli drafts create \
      --title "Why X matters" --subtitle "A short analysis" \
      --body-file ./post.md --audience only_paid \
      --seo-title "X explained" --seo-description "How X affects Y" \
      --cover-image https://substackcdn.com/.../cover.jpg --json
    
    # Update just the title and subtitle of an existing draft
    substack-pp-cli drafts update 12345 \
      --title "New title" --subtitle "New subtitle"
    

Portfolio & Analytics (local columnar store)

These commands read a local SQLite store populated by portfolio sync. The workflow is:

auth login --chrome  →  export SUBSTACK_PUBLICATION=<your-pub>  →  portfolio sync  →  portfolio / posts best / grep / subs churn / …

Custom-domain publications are supported: auth login --chrome captures the Creator-session cookie from the custom domain automatically.

  • portfolio sync — The data-population command. Discovers every publication you own and writes posts, subscribers, and drafts into the local columnar store. Must be run before the analytics commands below return cross-publication data.

    export SUBSTACK_PUBLICATION=mypub
    substack-pp-cli portfolio sync --json
    
  • portfolio — One-screen status of every publication you own: subscriber count, paid count, posts published, drafts pending, next scheduled. No tab-switching, no CSV exports.

    substack-pp-cli portfolio --json
    
  • posts best — Rank posts by views, likes, comments, or restacks within a window. --cross-pub aggregates across all your publications.

    substack-pp-cli posts best --by restacks --window 30d --cross-pub --json
    substack-pp-cli posts best --by views --limit 5 --publication mypub-en
    
  • posts twin <slug> --to <pub> — Duplicate a published post into another publication you own as a draft. Preserves paywall markers, section mapping, and re-uploads images to the target CDN.

    substack-pp-cli posts twin my-en-slug --to mypub-de --dry-run --json
    substack-pp-cli posts twin my-en-slug --to mypub-de
    
  • posts pair <en> <de> / posts pairs [--missing] — Record EN↔DE post pairings. --missing lists posts without a recorded twin — feed that output into posts twin to spin up the missing translations.

    substack-pp-cli posts pair my-en-slug my-de-slug
    substack-pp-cli posts pairs --missing --publication mypub-en --json
    
  • grep <query> — FTS5 full-text search across synced posts, notes, and comments, ranked by bm25, returning snippets and source URLs.

    substack-pp-cli grep "yield curve" --json
    substack-pp-cli grep "rate hike" --scope posts --publication mypub-en --since 2024-01-01
    
  • schedule board — ASCII calendar of the next N days showing scheduled posts across every publication you own. Multi-publication editorial overview in one screen.

    substack-pp-cli schedule board --days 30 --json
    
  • subs churn — Diff subscriber snapshots: who newly subscribed, who unsubscribed, who upgraded free→paid, who downgraded paid→free. Run --snapshot at least once first.

    substack-pp-cli subs churn --snapshot
    substack-pp-cli subs churn --since 7d --json --publication mypub-paid
    
  • subs cross-sell — Emails paid on one of your publications but free or absent on the others. Requires 2+ owned publications in the local store. The cross-sell list Substack's UI does not ship.

    substack-pp-cli subs cross-sell --json --limit 100
    

Recipes

Daily growth-loop morning ritual

substack-pp-cli growth attribution --days 7 --agent --select rank,note_excerpt,subs_acquired

Surfaces yesterday's Note→sub winners. Pair with substack-pp-cli engage reciprocity --days 7 --agent to see whose engagement reciprocates yours, and substack-pp-cli sync --since 24h ahead of time to keep the local store fresh.

Schedule a Note with the cadence guard

substack-pp-cli notes schedule --at 2030-05-13T09:00:00Z --body 'Tuesday hook line' --guard --json

Queues the Note locally; --guard refuses scheduling if it lands within 30 min of an existing own-Note (typed exit 2 + JSON diagnosis). Drop --guard or add --send to fire immediately.

Find this week's swap partners

substack-pp-cli recs find-partners --my-pub on --top 5 --json --select rank,handle,pub,overlap_score

Ranks candidate publications by audience overlap; pipe to your draft-outreach tool of choice (substack-pp-cli does the ranking; outreach drafting is left to your agent's prompt).

Capture a writer's voice fingerprint as JSON

substack-pp-cli voice fingerprint --handle alice --diff bob --json

Mechanical voice metrics for the named handle, with a delta against another writer when --diff is set. Save the JSON yourself; agent generation prompts can ingest it.

Surface deeply nested Note metadata with --select

substack-pp-cli notes get c-12345 --agent --select id,body,attachments.url,attachments.image_url,attachments.published_bylines.name,attachments.published_bylines.handle,context.users.name

Notes responses are deeply nested (attachments, bylines, contextual users). Dotted --select narrows the payload so an agent doesn't burn context parsing 30KB of JSON it doesn't need.

Bootstrap the portfolio analytics store

substack-pp-cli auth login --chrome
export SUBSTACK_PUBLICATION=mypub
substack-pp-cli portfolio sync --json
substack-pp-cli portfolio --json

Run once after login. Every cross-publication analytics command reads the local store that portfolio sync populates.

Publish a rich draft with SEO and cover image

export SUBSTACK_PUBLICATION=mypub
substack-pp-cli drafts create \
  --title "The case for X" \
  --subtitle "Three reasons it matters now" \
  --body-file ./post.md \
  --audience only_paid \
  --seo-title "Case for X" \
  --seo-description "Why X matters for Y" \
  --cover-image https://substackcdn.com/.../cover.jpg \
  --json

Twin your best EN post into a DE publication

# Sync first so the local store has current posts
substack-pp-cli portfolio sync --json

# Find top post by restacks
substack-pp-cli posts best --by restacks --limit 1 --publication mypub-en --json

# Preview, then create the draft
substack-pp-cli posts twin my-en-slug --to mypub-de --dry-run --json
substack-pp-cli posts twin my-en-slug --to mypub-de --json

Weekly subscriber churn digest

# Run once to set a baseline, then weekly to see movement
substack-pp-cli subs churn --snapshot
substack-pp-cli subs churn --since 7d --json --publication mypub-paid

Full-text search across all your publications

substack-pp-cli grep "interest rates" --scope posts --json
substack-pp-cli grep "reader question" --scope notes --since 2025-01-01 --limit 20 --json

Usage

Run substack-pp-cli --help for the full command reference and flag list.

Commands

categories

Site-wide Substack category list — culture, technology, food, etc.

  • substack-pp-cli categories list - List all Substack categories
  • substack-pp-cli categories list-publications - List publications in a category

comments

Long-form post comments (distinct from Notes)

  • substack-pp-cli comments get - Get a single comment by ID (same shape as a Note — Substack treats them uniformly)
  • substack-pp-cli comments list - List comments on a post

discover

Discovery surfaces — search publications, embed metadata

  • substack-pp-cli discover - Search Substack publications by query

drafts

Drafts CRUD + publish + schedule

  • substack-pp-cli drafts create - Create a new draft
  • substack-pp-cli drafts delete - Delete a draft
  • substack-pp-cli drafts get - Get a draft by ID
  • substack-pp-cli drafts list - List drafts
  • substack-pp-cli drafts prepublish - Validate a draft for publication; returns blockers
  • substack-pp-cli drafts publish - Publish a draft now
  • substack-pp-cli drafts schedule - Schedule a draft for future publish (or unschedule with --post-date null)
  • substack-pp-cli drafts update - Update an existing draft

feed

RSS feed for a publication

  • substack-pp-cli feed - RSS XML feed (returns XML; use --raw to dump)

images

Image upload (data-URI JSON, not multipart)

  • substack-pp-cli images - Upload an image; returns CDN URL. Body is data-URI JSON.

inbox

Authenticated reader feed (home feed) — Notes + posts surfaced for the current user

  • substack-pp-cli inbox home - Authenticated home feed
  • substack-pp-cli inbox reader-posts - Posts feed for current user

notes

Substack Notes — short-form posts (Substack treats Notes as comments internally)

  • substack-pp-cli notes create - Post a new Note (POST /comment/feed). Body is ProseMirror JSON.
  • substack-pp-cli notes get - Get a single Note by ID
  • substack-pp-cli notes list-by-profile - List Notes by a profile (cursor pagination)
  • substack-pp-cli notes reply - Reply to an existing Note (parent_id + ProseMirror body)

grep

Full-text search across synced posts, notes, and comments

  • substack-pp-cli grep <query> - FTS5 search ranked by bm25, returning snippets and source URLs. Flags: --scope posts|notes|comments|all, --publication, --since, --limit

portfolio

Multi-publication status dashboard and data-population

  • substack-pp-cli portfolio - One-screen status of every publication you own (subs, paid, posts, drafts, next scheduled). Run portfolio sync first.
  • substack-pp-cli portfolio sync - Discover every publication you own and populate the local columnar store (publications/posts/subscribers/drafts). The prerequisite for all cross-publication analytics commands.

posts

Long-form posts and archives on a specific publication

  • substack-pp-cli posts archive - Public archive of a publication's posts
  • substack-pp-cli posts best - Rank cached posts by engagement metric (--by views|likes|comments|restacks, --window, --cross-pub, --limit, --publication)
  • substack-pp-cli posts get-by-slug - Get a published post by URL slug
  • substack-pp-cli posts list-published - List published posts on the publication (auth required)
  • substack-pp-cli posts pair <en-slug> <de-slug> - Record an EN↔DE translation pairing in the local table
  • substack-pp-cli posts pairs - List recorded post pairs; --missing shows posts without a twin; --publication filters to one pub
  • substack-pp-cli posts ranked-authors - Ranked list of authors for a publication
  • substack-pp-cli posts twin <slug> --to <pub> - Duplicate a published post into another publication you own as a draft (re-uploads images, preserves paywall markers)

profiles

Substack profiles — your own and other writers'

  • substack-pp-cli profiles from-linkedin - Look up a Substack profile from a LinkedIn handle
  • substack-pp-cli profiles get-by-handle - Get a public profile by handle (e.g. mvanhorn)
  • substack-pp-cli profiles get-by-id - Get a public profile by numeric user ID
  • substack-pp-cli profiles handle-options - Available handle suggestions for the current user
  • substack-pp-cli profiles posts - All posts by an author across publications
  • substack-pp-cli profiles self - Get the authenticated user's profile

recommendations

Substack Recommendations — outbound (publications I recommend)

  • substack-pp-cli recommendations <publication_id> - List the publications a publication recommends

sections

Sections of a publication (newsletters can have multiple)

  • substack-pp-cli sections - List sections + subscriptions

settings

Account settings + connectivity probe (used by doctor)

  • substack-pp-cli settings get - Get account settings
  • substack-pp-cli settings ping - Connectivity probe (non-destructive PUT used by doctor)

schedule

Cross-publication editorial scheduling

  • substack-pp-cli schedule board - ASCII calendar of the next N days (--days) of scheduled posts across all owned publications

subs

Subscriber count, churn diff, and cross-sell analytics

  • substack-pp-cli subs authors - List bylined authors of a publication
  • substack-pp-cli subs churn - Diff subscriber snapshots (new/unsubscribed/upgraded/downgraded). Use --snapshot to create a baseline, then --since to diff. Flags: --publication, --since, --snapshot
  • substack-pp-cli subs count - Get subscriber count (read off the launch-checklist payload)
  • substack-pp-cli subs cross-sell - Emails paid on one publication but free/absent on others (requires 2+ owned pubs). Flags: --limit

tags

Post tags

  • substack-pp-cli tags create - Create a new tag
  • substack-pp-cli tags list - List all tags for the publication

Output Formats

# Human-readable table (default in terminal, JSON when piped)
substack-pp-cli categories list

# JSON for scripting and agents
substack-pp-cli categories list --json

# Filter to specific fields
substack-pp-cli categories list --json --select id,name,status

# Dry run — show the request without sending
substack-pp-cli categories list --dry-run

# Agent mode — JSON + compact + no prompts in one flag
substack-pp-cli categories list --agent

Agent Usage

This CLI is designed for AI agent consumption:

  • Non-interactive - never prompts, every input is a flag
  • Pipeable - --json output to stdout, errors to stderr
  • Filterable - --select id,name returns only fields you need
  • Previewable - --dry-run shows the request without sending
  • Explicit retries - add --idempotent to create retries and --ignore-missing to delete retries when a no-op success is acceptable
  • Confirmable - --yes for explicit confirmation of destructive actions
  • Piped input - write commands can accept structured input when their help lists --stdin
  • Offline-friendly - sync/search commands can use the local SQLite store when available
  • Agent-safe by default - no colors or formatting unless --human-friendly is set

Exit codes: 0 success, 2 usage error, 3 not found, 4 auth error, 5 API error, 7 rate limited, 10 config error.

Freshness

This CLI owns bounded freshness for registered store-backed read command paths. In --data-source auto mode, covered commands check the local SQLite store before serving results; stale or missing resources trigger a bounded refresh, and refresh failures fall back to the existing local data with a warning. --data-source local never refreshes, and --data-source live reads the API without mutating the local store.

Set SUBSTACK_NO_AUTO_REFRESH=1 to disable the pre-read freshness hook while preserving the selected data source.

Covered command paths:

  • substack-pp-cli categories
  • substack-pp-cli categories get
  • substack-pp-cli categories list
  • substack-pp-cli categories search
  • substack-pp-cli drafts
  • substack-pp-cli drafts get
  • substack-pp-cli drafts list
  • substack-pp-cli drafts search
  • substack-pp-cli inbox
  • substack-pp-cli inbox get
  • substack-pp-cli inbox list
  • substack-pp-cli inbox search
  • substack-pp-cli inbox-posts
  • substack-pp-cli inbox-posts get
  • substack-pp-cli inbox-posts list
  • substack-pp-cli inbox-posts search
  • substack-pp-cli posts
  • substack-pp-cli posts get
  • substack-pp-cli posts list
  • substack-pp-cli posts search
  • substack-pp-cli posts-published
  • substack-pp-cli posts-published get
  • substack-pp-cli posts-published list
  • substack-pp-cli posts-published search
  • substack-pp-cli posts-ranked
  • substack-pp-cli posts-ranked get
  • substack-pp-cli posts-ranked list
  • substack-pp-cli posts-ranked search
  • substack-pp-cli profiles
  • substack-pp-cli profiles get
  • substack-pp-cli profiles list
  • substack-pp-cli profiles search
  • substack-pp-cli sections
  • substack-pp-cli sections get
  • substack-pp-cli sections list
  • substack-pp-cli sections search
  • substack-pp-cli subs
  • substack-pp-cli subs get
  • substack-pp-cli subs list
  • substack-pp-cli subs search
  • substack-pp-cli tags
  • substack-pp-cli tags get
  • substack-pp-cli tags list
  • substack-pp-cli tags search

JSON outputs that use the generated provenance envelope include freshness metadata at meta.freshness. This metadata describes the freshness decision for the covered command path; it does not claim full historical backfill or API-specific enrichment.

Runtime Endpoint

This CLI resolves endpoint placeholders at runtime, so one installed binary can target different tenants or API versions without regeneration.

Endpoint environment variables:

  • SUBSTACK_PUBLICATION resolves {publication}

Base URL: https://substack.com/api/v1

Health Check

substack-pp-cli doctor

Verifies configuration, credentials, and connectivity to the API.

Configuration

Config file: ~/.config/substack-pp-cli/config.toml

Static request headers can be configured under headers; per-command header overrides take precedence.

Troubleshooting

Authentication errors (exit code 4)

  • Run substack-pp-cli doctor to check credentials Not found errors (exit code 3)
  • Check the resource ID is correct
  • Run the list command to see available items

API-specific

  • 401 Unauthorized on any write command — Cookie expired. Run substack-pp-cli auth login --chrome to re-import (also aliased as --browser).
  • RSS / posts feed returns 403 with 'Just a moment...' HTML — Cloudflare TLS fingerprinting. Run substack-pp-cli doctor to confirm; if it reports the RSS leg blocked, retry from a different IP or use posts archive (uses the JSON API which Cloudflare doesn't gate as aggressively).
  • Notes posted at the same minute fail or get hidden by the algorithm — Re-run with --guard (default in notes schedule); the cadence guard will reject sub-30-min spacing with exit 2 and a JSON diagnosis explaining the violation.
  • engage like / engage restack printed a curl-equivalent instead of firing — That's the default — these endpoints aren't in any community wrapper, so the CLI prints the request shape so you can preflight it. Add --send to actually fire.

Sources & Inspiration

This CLI was built by studying these projects and resources:

Generated by CLI Printing Press

For agents: pass /documentation/substack/agents.md to install this CLI from a prompt.