Welcome to the AgentCulture mesh. This is your onboarding brief and the source
of truth for what substack-cli is for. It is self-contained: read it, then
run /init to expand the CLAUDE.md seed into a real runtime prompt, and build
from here.
1. What this repo is
substack-cli lets agents manage a Substack publication and account —
publish and schedule posts, read posts and comments, run audience and post
statistics, and manage subscribers — from the command line, agent-first.
|
Name |
| Repo / agent suffix |
substack-cli |
| Console command |
substack |
| Import package |
substack_cli |
| PyPI distribution |
substack-cli (already live — genesis 0.9.0 published) |
The import package is deliberately decoupled from the command: a top-level
substack import would shadow the existing third-party clients on PyPI
(python-substack imports as substack). Keep it substack_cli.
This is an unofficial community tool, not affiliated with or endorsed by
Substack. Carry that disclaimer in the README, learn output and the PyPI
description (the qodo-cli precedent).
2. The fact that shapes everything: three access tiers, one of them unofficial
Substack does have an official
Developer API
(introduced 2026, governed by its own Terms of Use),
but it is narrow: its documented surface is public-profile lookup for
creators, gated by a ToS agreement and a manual approval (7–10 business days).
It is not a publishing, stats or subscriber-management API.
So the verbs the operator asked for sit on three different tiers:
| Tier |
What it covers |
Stability |
| Official Developer API |
public creator-profile data |
documented, ToS-gated |
| Public surfaces |
a publication's RSS feed and public archive/post pages |
stable-ish, no auth |
| Internal web-app endpoints |
drafts, publish, schedule, comments, dashboard stats, subscribers |
undocumented — can change without notice |
Requirements that follow:
- Every verb declares its tier — in
explain, in --json output, and in
its docs. An agent must be able to tell whether the thing it just called
rests on a documented contract or on a reverse-engineered one.
- All internal-endpoint calls live behind one adapter module, with
recorded response fixtures. When Substack changes a shape, one module and
its fixtures change — not every verb. Detect a shape change as a typed
error ("Substack's internal API response changed"), never a KeyError
traceback.
- The ToS question is a parked operator decision, not yours to settle
silently (see §6). Build so the internal tier can be disabled wholesale.
Prior art worth reading before designing your own client (cite, don't import):
python-substack (Markdown →
drafts, with its own CLI + MCP) and
substack_api (read-only unofficial
wrapper). Evaluate them; don't take a hard runtime dependency on an
unofficial wrapper whose breakage you can't fix.
3. Design constraints (the real content of this brief)
3.1 Sending an email cannot be undone — separate it from publishing
The AgentCulture rule is that every write verb defaults to dry-run and
--apply commits. For Substack that isn't enough on its own. Publishing a
post can also email every subscriber, and an email cannot be recalled.
- Keep "publish to the web" and "send to subscribers' inboxes" as separate,
explicit decisions. Emailing needs its own flag (e.g. --send-email) in
addition to --apply. It never happens as a side effect of publishing.
- The dry-run of a send must show what will go out and to how many
recipients (and which audience: all / paid / free / a section).
- Scheduling is a write, too. Unscheduling and deleting are writes. All of them
default to dry-run.
- Agents call CLIs in loops, so make sends idempotent: re-running the same
publish must not email the list twice.
3.2 Auth: a session, never a password on disk
The internal tier authenticates with a web session (a cookie), not an API key.
- Never store the account password. Read the session from the environment or
from grant, the mesh's
agent-first secrets manager, rather than inventing a config file.
whoami is the smallest auth probe: which account, which publications, and
whether the session is valid. An expired or missing session is a
first-class typed error with a remediation hint, not a bare 401/403.
- Accounts can own several publications. Every publication-scoped verb
takes an explicit --publication. Never default to "the first one" for a
write.
- If headless email login turns out to be gated (captcha, magic link),
don't build browser automation here. Open a seam issue on
webglass-cli, which owns
agent-first browsing.
3.3 Subscriber data is personal data
Subscriber lists are other people's email addresses.
- Default output is counts and aggregates, not addresses.
- Individual addresses appear only on an explicit verb that writes to an
explicit --output PATH. Never write them to stdout by default, never log
them, never commit them. Fixtures must use synthetic addresses.
- Adding, comping or removing subscribers is a write verb (dry-run default),
and bulk imports show a count and a sample before --apply.
3.4 Content is a document tree, not a string
Substack's editor stores post bodies as a structured document (verify the
current schema against the live editor; it has historically been a
ProseMirror-style JSON tree), not as Markdown or HTML.
- Make Markdown ⇄ Substack body a pure function, separate from HTTP, with
round-trip tests: md → body → md must be stable on a fixture corpus.
- Document which Markdown constructs are lossy (embeds, footnotes, buttons,
paywall markers, subscribe widgets) instead of silently dropping them.
- Images need uploading through Substack before a body can reference them.
Keep that a separate step so a dry-run can show "3 images would be
uploaded".
3.5 Statistics: report Substack's numbers with their definitions, don't invent your own
- Every metric carries its source (which dashboard figure or endpoint),
its time window and its as-of timestamp.
- "Not available" is not zero. Stats that the account's plan or role can't
see must say so.
- Open rates are inflated by mail-client privacy features that pre-fetch
images (Apple Mail Privacy Protection being the famous one). Label opens
accordingly rather than presenting them as reads.
- Any derived metric you compute (growth rate, conversion, cohort retention)
is labelled as derived, with its formula available via explain.
3.6 Reading other people's publications: public data only
- Reading your own publication's content, drafts and comments is in scope.
- Reading other publications is limited to what they publish publicly (feed,
public posts). No bulk archive scraping, no paywall circumvention. Paid
content is readable only when the authenticated account is entitled to it.
- Throttle: low default concurrency, back off on
429/5xx, and handle
pagination inside the client so no verb has to remember it.
3.7 Tests are network-free
The unit suite must run with no Substack account and no network: recorded
fixtures behind the adapter, with the HTTP layer injectable. Any live smoke
tests are opt-in, marked, and skipped in CI.
4. A starting verb surface (a sketch — you own the final shape)
- Read:
whoami, publication list|show, post list|show,
comments list, feed read <publication> (public tier)
- Write:
draft create|update --from FILE.md, draft preview,
post publish [--send-email], post schedule --at, post unschedule
(all dry-run by default)
- Stats:
stats posts, stats audience, stats growth
- Subscribers:
subscribers count, subscribers export --output PATH,
subscribers add|remove (write, dry-run default)
- Plus the template's agent-affordance verbs (
learn, explain) with every
verb documented in the explain catalog, including its tier from §2.
Suggested order: M1 read-only (whoami, public feed, own posts, stats read),
then M2 drafts (the Markdown ⇄ body function plus create/update), then
M3 publish/schedule with the §3.1 email guard, then M4 subscribers.
Shipping read-only first gives the mesh value before any irreversible verb
exists.
5. Lanes and seams (open issues rather than absorbing)
notion-agent: the closest sibling in shape (a SaaS control CLI with a
token, pagination and rate limits, and a document-tree body format). Read its
brief and design before designing yours. Converge on shared patterns rather
than diverging accidentally.
storybook-cli: produces shareable write-ups of agent work. A natural
consumer of substack draft create. Offer it a seam; don't render sites
here.
grant: where the session secret should live (§3.2).
webglass-cli: any browser-driven login or scraping fallback (§3.2).
6. Parked unknowns (settle these with the operator, don't guess)
- ToS stance on the internal tier. Automating Substack's undocumented
web-app endpoints may conflict with Substack's terms. Whether this tool
ships the internal tier on by default, behind an opt-in, or not at all is
the operator's call. Surface it before M2.
- Which account and publication(s) this will run against. Unknown today.
Nothing in the repo may hardcode one.
- Official Developer API access: whether to apply for it (the approval is
manual and slow) and whether its profile surface earns a verb at all.
- Notes and Chat (Substack's short-form and community surfaces): in or
out of scope? Parked. Don't build them in M1–M4.
- Own client vs. building on prior art (§2): your evaluation, reported
back as a decision with reasons.
7. Housekeeping owed
-
Run /init to expand the CLAUDE.md seed into a full runtime prompt.
-
Keep culture.yaml (backend: claude) and the prompt file consistent.
-
Add the unofficial-tool / trademark disclaimer (§1).
-
The canonical skill kit is already vendored from the template, and you're
registered in guildmaster's docs/skill-sources.md ledger. Skill updates
will arrive as guild teach issues.
-
guildmaster (Claude)
Welcome to the AgentCulture mesh. This is your onboarding brief and the source
of truth for what
substack-cliis for. It is self-contained: read it, thenrun
/initto expand theCLAUDE.mdseed into a real runtime prompt, and buildfrom here.
1. What this repo is
substack-clilets agents manage a Substack publication and account —publish and schedule posts, read posts and comments, run audience and post
statistics, and manage subscribers — from the command line, agent-first.
substack-clisubstacksubstack_clisubstack-cli(already live — genesis0.9.0published)The import package is deliberately decoupled from the command: a top-level
substackimport would shadow the existing third-party clients on PyPI(
python-substackimports assubstack). Keep itsubstack_cli.This is an unofficial community tool, not affiliated with or endorsed by
Substack. Carry that disclaimer in the README,
learnoutput and the PyPIdescription (the
qodo-cliprecedent).2. The fact that shapes everything: three access tiers, one of them unofficial
Substack does have an official
Developer API
(introduced 2026, governed by its own Terms of Use),
but it is narrow: its documented surface is public-profile lookup for
creators, gated by a ToS agreement and a manual approval (7–10 business days).
It is not a publishing, stats or subscriber-management API.
So the verbs the operator asked for sit on three different tiers:
Requirements that follow:
explain, in--jsonoutput, and inits docs. An agent must be able to tell whether the thing it just called
rests on a documented contract or on a reverse-engineered one.
recorded response fixtures. When Substack changes a shape, one module and
its fixtures change — not every verb. Detect a shape change as a typed
error ("Substack's internal API response changed"), never a
KeyErrortraceback.
silently (see §6). Build so the internal tier can be disabled wholesale.
Prior art worth reading before designing your own client (cite, don't import):
python-substack(Markdown →drafts, with its own CLI + MCP) and
substack_api(read-only unofficialwrapper). Evaluate them; don't take a hard runtime dependency on an
unofficial wrapper whose breakage you can't fix.
3. Design constraints (the real content of this brief)
3.1 Sending an email cannot be undone — separate it from publishing
The AgentCulture rule is that every write verb defaults to dry-run and
--applycommits. For Substack that isn't enough on its own. Publishing apost can also email every subscriber, and an email cannot be recalled.
explicit decisions. Emailing needs its own flag (e.g.
--send-email) inaddition to
--apply. It never happens as a side effect of publishing.recipients (and which audience: all / paid / free / a section).
default to dry-run.
publish must not email the list twice.
3.2 Auth: a session, never a password on disk
The internal tier authenticates with a web session (a cookie), not an API key.
from
grant, the mesh'sagent-first secrets manager, rather than inventing a config file.
whoamiis the smallest auth probe: which account, which publications, andwhether the session is valid. An expired or missing session is a
first-class typed error with a remediation hint, not a bare 401/403.
takes an explicit
--publication. Never default to "the first one" for awrite.
don't build browser automation here. Open a seam issue on
webglass-cli, which ownsagent-first browsing.
3.3 Subscriber data is personal data
Subscriber lists are other people's email addresses.
explicit
--output PATH. Never write them to stdout by default, never logthem, never commit them. Fixtures must use synthetic addresses.
and bulk imports show a count and a sample before
--apply.3.4 Content is a document tree, not a string
Substack's editor stores post bodies as a structured document (verify the
current schema against the live editor; it has historically been a
ProseMirror-style JSON tree), not as Markdown or HTML.
round-trip tests:
md → body → mdmust be stable on a fixture corpus.paywall markers, subscribe widgets) instead of silently dropping them.
Keep that a separate step so a dry-run can show "3 images would be
uploaded".
3.5 Statistics: report Substack's numbers with their definitions, don't invent your own
its time window and its as-of timestamp.
see must say so.
images (Apple Mail Privacy Protection being the famous one). Label opens
accordingly rather than presenting them as reads.
is labelled as derived, with its formula available via
explain.3.6 Reading other people's publications: public data only
public posts). No bulk archive scraping, no paywall circumvention. Paid
content is readable only when the authenticated account is entitled to it.
429/5xx, and handlepagination inside the client so no verb has to remember it.
3.7 Tests are network-free
The unit suite must run with no Substack account and no network: recorded
fixtures behind the adapter, with the HTTP layer injectable. Any live smoke
tests are opt-in, marked, and skipped in CI.
4. A starting verb surface (a sketch — you own the final shape)
whoami,publication list|show,post list|show,comments list,feed read <publication>(public tier)draft create|update --from FILE.md,draft preview,post publish [--send-email],post schedule --at,post unschedule(all dry-run by default)
stats posts,stats audience,stats growthsubscribers count,subscribers export --output PATH,subscribers add|remove(write, dry-run default)learn,explain) with everyverb documented in the explain catalog, including its tier from §2.
Suggested order: M1 read-only (whoami, public feed, own posts, stats read),
then M2 drafts (the Markdown ⇄ body function plus create/update), then
M3 publish/schedule with the §3.1 email guard, then M4 subscribers.
Shipping read-only first gives the mesh value before any irreversible verb
exists.
5. Lanes and seams (open issues rather than absorbing)
notion-agent: the closest sibling in shape (a SaaS control CLI with atoken, pagination and rate limits, and a document-tree body format). Read its
brief and design before designing yours. Converge on shared patterns rather
than diverging accidentally.
storybook-cli: produces shareable write-ups of agent work. A naturalconsumer of
substack draft create. Offer it a seam; don't render siteshere.
grant: where the session secret should live (§3.2).webglass-cli: any browser-driven login or scraping fallback (§3.2).6. Parked unknowns (settle these with the operator, don't guess)
web-app endpoints may conflict with Substack's terms. Whether this tool
ships the internal tier on by default, behind an opt-in, or not at all is
the operator's call. Surface it before M2.
Nothing in the repo may hardcode one.
manual and slow) and whether its profile surface earns a verb at all.
out of scope? Parked. Don't build them in M1–M4.
back as a decision with reasons.
7. Housekeeping owed
Run
/initto expand theCLAUDE.mdseed into a full runtime prompt.Keep
culture.yaml(backend: claude) and the prompt file consistent.Add the unofficial-tool / trademark disclaimer (§1).
The canonical skill kit is already vendored from the template, and you're
registered in guildmaster's
docs/skill-sources.mdledger. Skill updateswill arrive as
guild teachissues.guildmaster (Claude)