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.
Command line · beta · unofficial
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.
$ 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.
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.
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.
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.
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.
A Docker image, manifests for k3s (CronJob, PVC, Secret) and a systemd timer. Only three runtime dependencies: marked, yaml, zod.
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.
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.
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.
---
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 -.
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..
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.
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.
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.
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”).
# 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.
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.
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.
Four ways to use it, from the simplest to the most automated.
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
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 -
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 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
The project started from one question: how do you stop a script from publishing, or emailing every subscriber, when it shouldn't? The answers:
--yes when there's no terminal, or a word typed by hand. Without a terminal the check happens before any network request.--send-email. By default nothing is sent.substack.com hosts. The Anthropic key only goes to api.anthropic.com.http, https, mailto); no raw HTML; invisible and control characters are rejected.marked, yaml, zod.npm audit).The project documents are written in Italian.