Substack CLI

Command line · beta · unofficial

Your newsletter from the terminal, without publishing by mistake.

Create drafts from Markdown, queue notes and schedule them, and if you like, let an LLM write a first draft, even a local one. Publishing always takes your explicit yes.

Draft no. 1stdout
$ substack article draft post.md
Draft created: id 123456789

$ substack article publish 123456789
Error: Non-interactive publish: add --yes
to confirm explicitly

$ substack note add "A short note"
$ substack note schedule a1b2c3d4e5f6 \
    --at 2026-10-09T09:00:00+02:00

$ substack notes run-due   # cron / k3s

Without --yes the command stops before touching the network.

§ 1

The product

Substack CLI is a command-line program that takes newsletter writing out of the browser: you write in Markdown with your own editor, the CLI creates the draft online, you review it in Substack's editor and publish it when you decide to. Short notes live in a local queue and go out by hand or on a schedule.

Drafts from Markdown

Headings, bold, italics, nested lists, quotes, code and links become the body of an online draft. Anything it doesn't support (raw HTML, tables) is rejected with an error, so no text is lost silently.

A queue of notes

Notes are kept locally as drafts. You publish them by hand or give them a time: notes run-due takes care of the rest. A note whose outcome is uncertain is never published twice.

Optional generation

Anthropic, or any OpenAI-compatible server (llama.cpp, Ollama, vLLM, LM Studio). Generated text only ever becomes a draft or a queued note: it never publishes anything.

On a dedicated machine

A Docker image, manifests for k3s (CronJob, PVC, Secret) and a systemd timer. Only three runtime dependencies: marked, yaml, zod.

§ 2

How to use it

  1. Install and build

    git clone https://github.com/fullo/substack-cli.git
    cd substack-cli
    npm run setup

    You need Node 22.18 or later. npm run setup installs the dependencies, builds, and runs npm link, which registers the substack command as a symlink to the repository, so every later npm run build is picked up right away. If npm's global folder isn't writable by your user, use a version manager such as nvm or set a prefix in your home rather than sudo. Without the link the command is node dist/cli/main.js. To remove it: npm unlink -g substack-cli.

  2. Set up your publication

    substack config init --publication your-newsletter
    substack config init --publication your-newsletter --lang it --force   # messages in Italian

    That's the part before .substack.com. substack config show prints the configuration in effect. Messages are in English by default; --lang it (or the SUBSTACK_LANG=it environment variable, which takes precedence) switches them to Italian, confirmation words included.

  3. Give the CLI your session cookie

    substack auth guide    # step-by-step instructions
    substack auth set      # the value is not echoed
    substack auth check    # checks that the session is valid

    Substack has no API keys: the CLI uses your account's substack.sid cookie. Treat it like a password and don't paste it into chats or committed files. You can also pass it through the SUBSTACK_SID environment variable.

  4. Write and create a draft

    ---
    title: The title of your post
    subtitle: A subtitle, if you need one
    ---
    
    ## A section heading
    
    Text with **bold**, *italics* and a [link](https://example.com).
    
    - a list
    - with two items
    substack article draft post.md --dry-run   # try it, nothing is sent
    substack article draft post.md             # create the draft online
    substack article list                      # list your drafts

    The file starts with front matter: title is required, subtitle is optional. You can also pipe the text from another program with cat post.md | substack article draft -.

  5. Find and download your articles

    substack article list --status published                  # id, title, link
    substack article list --status published --limit 50 --page 2   # pages of 10, 25, 50 or 100
    substack article list --search "green coding"             # search published articles
    substack article export 123456789 --out post.md           # one article as Markdown

    Each command makes a single request and never walks through the pages by itself. export writes Markdown with front matter; embeds and widgets that have no Markdown form become a comment and a warning. There is deliberately no “download everything” command..

  6. Fix a draft without opening the browser

    substack article update 123456789 --title "A better title" --dry-run
    substack article update 123456789 --title "A better title"
    # Type "update" to confirm:

    Only the fields that change are sent, and published posts are left alone. With --file the Markdown file replaces body, title and subtitle; try that on a test draft first, because it overwrites edits made in the web editor.

  7. Add images and a cover

    substack image upload cover.png                              # uploads and prints the URL
    substack article draft post.md --cover cover.png             # upload, create the draft, set the cover
    substack article update 123456789 --cover cover.png --yes    # set the cover on an existing draft
    substack article update 123456789 --cover https://substack-post-media.s3.amazonaws.com/... --yes   # or an already uploaded URL

    Accepted formats: PNG, JPEG, GIF and WebP, up to 10 MB. With article draft --cover the image is uploaded before the draft is created; if setting the cover then fails, the message gives the draft id and the command to retry just the cover. The upload and cover endpoints are not confirmed yet: after updating, the CLI rereads the draft and, if the cover isn't set, exits with code 3.

  8. Publish, with a confirmation

    substack article publish 123456789
    # Type "publish" to confirm:
    
    substack article publish 123456789 --yes                 # from a script
    substack article publish 123456789 --yes --send-email    # also email your subscribers

    In a terminal you're asked to type a confirmation word; from a script you need --yes, otherwise the command stops straight away. The email to subscribers only goes out with --send-email.

    substack article schedule 123456789 --at 2026-10-09T09:00:00+02:00
    substack article schedule 123456789 --at 2026-10-09T09:00:00+02:00 --email only_free
    substack article schedule 123456789 --cancel

    Scheduling is different: Substack has no “no email” option, so with article schedule the email always goes out. Choose who gets it with --email (everyone by default, only_free, only_paid or founding); the audience appears in the summary before you confirm.

  9. Notes: a local queue, by hand or on a schedule

    substack note add "A short note with a [link](https://example.com)"
    substack note list --status draft
    substack note schedule a1b2c3d4e5f6 --at 2026-10-09T09:00:00+02:00
    substack note publish a1b2c3d4e5f6        # now, by hand
    substack note unschedule a1b2c3d4e5f6     # back to draft

    Dates always need a time zone: +02:00, or Z for UTC. Scheduled notes are published by substack notes run-due, which is meant to run every minute (see “On an always-on machine”).

  10. Get help from an LLM (optional)

    # with a local server, for example llama.cpp
    llama-server -m model.gguf --port 8080
    substack config init --publication your-newsletter --force \
        --provider openai-compat --llm-base-url http://localhost:8080 --model local
    
    substack generate article --topic "What green coding is" --draft
    substack generate note --topic "A tip on green coding"

    With --provider anthropic the CLI uses Anthropic's API instead, with the ANTHROPIC_API_KEY environment variable. Text is generated in the CLI's language unless you pass --lang. The generated article is saved locally and, with --draft, becomes an online draft; the note goes into the queue. Nothing is published.

  11. On an always-on machine

    docker build -f deploy/Dockerfile -t substack-cli:0.1.0 .
    docker save substack-cli:0.1.0 | sudo k3s ctr images import -
    
    kubectl apply -f deploy/k3s/namespace.yaml -f deploy/k3s/pvc.yaml
    read -rs SID && kubectl -n substack create secret generic substack-secrets \
        --from-literal=SUBSTACK_SID="$SID"; unset SID
    # in deploy/k3s/configmap.yaml replace SOSTITUISCI-SUBDOMAIN with yours
    kubectl apply -f deploy/k3s/configmap.yaml -f deploy/k3s/cronjob.yaml

    The CronJob runs notes run-due every minute. On a Linux VM you can use the systemd timer in deploy/systemd instead. When the cookie expires the command exits with code 2 and the Job shows as failed, so you notice.

Command reference

auth guide · set · check · loginGet, store and check the cookie (login opens a browser, with Playwright installed separately).
config init · showCreate and show the configuration (--lang en|it).
article draft <file|->Create a draft from Markdown. --cover, --dry-run.
article listList drafts, or published articles with --status published (id, title, link). --limit 10|25|50|100, --page, --search.
article export <id>Download an article as Markdown with front matter. --out, --force.
article update <id>Edit an unpublished draft. --title, --subtitle, --file, --cover, --yes, --dry-run.
article publish <id>Publish a draft. --yes, --send-email, --dry-run.
article schedule <id>Schedule with Substack's own scheduler (--at) or cancel it (--cancel). --email picks the audience, --yes, --dry-run.
image upload <file>Upload an image (PNG, JPEG, GIF, WebP, max 10 MB) and print its URL.
note add <text|->Add a note to the local queue.
note list · schedule · unscheduleList (--status), schedule (--at) or move back to draft.
note publish · resolvePublish now; resolve --published|--retry settles a note left in doubt.
notes run-duePublish scheduled notes that are due (cron, systemd, k3s).
generate article · noteGenerate text with an LLM (--topic, --lang, --provider, --draft).

Most commands accept --json for output other programs can read.

§ 3

Use cases

Four ways to use it, from the simplest to the most automated.

From your editor to a draft

Write the post as a Markdown file, perhaps in a Git repository alongside the others. When it's ready, send it to Substack and review it in their editor.

substack article draft post.md --dry-run
substack article draft post.md
# open the link, review, then:
substack article publish 123456789

Writing with an assistant

An assistant prepares the text (a coding assistant in the terminal, for instance) and the CLI turns it into a draft through stdin. You decide whether and when to publish.

cat assistant-draft.md | substack article draft -

A week of scheduled notes

Prepare the notes in one sitting and set the times. A timer on your machine publishes them at the right moment; if something goes wrong (expired cookie, Substack not responding) you see it as an error, and a doubtful note is never published twice.

substack note add "Monday: new issue is out"
substack note schedule a1b2c3d4e5f6 --at 2026-10-12T09:00:00+02:00
substack notes run-due        # every minute, from cron

A first draft from a local model

A model running on your own computer (llama.cpp, Ollama…) writes a first draft on the topic you give it. The result becomes an online draft to polish: nothing goes out without your review.

substack generate article \
    --topic "What green coding is" --draft
§ 4

House rules

The project started from one question: how do you stop a script from publishing, or emailing every subscriber, when it shouldn't? The answers:

  • Publishing requires --yes when there's no terminal, or a word typed by hand. Without a terminal the check happens before any network request.
  • The email to subscribers only goes out with --send-email. By default nothing is sent.
  • The session cookie never ends up in output, logs, data files or error messages, and is only sent to substack.com hosts. The Anthropic key only goes to api.anthropic.com.
  • Links and images only with safe schemes (http, https, mailto); no raw HTML; invisible and control characters are rejected.
  • No double publishing: the note queue is protected by a lock, and a note whose fate is unknown stays put until you decide.
§ 5

By the numbers

398tests: unit, functional (the real CLI against fake servers) and security.
96%mutation score across 1523 mutants.
3runtime dependencies, with no transitive dependencies: marked, yaml, zod.
0known vulnerabilities in runtime dependencies (npm audit).
§ 6

Documents

The project documents are written in Italian.

  • READMEInstallation, commands, deployment, troubleshooting.
  • Design specArchitecture, security rules, testing strategy.
  • Red team reportIssues found, attacks tried, k3s acceptance run.
  • Equivalent mutantsWhere mutation testing can't tell you more, and why.
  • API contractHow Substack responds, endpoint by endpoint.
  • DeploymentDockerfile, k3s manifests, systemd units.