Manages publishing and editing for the Alternative Partners Substack publication via the internal REST API. No Playwright, no browser — pure requests with a session cookie.
This skill requires a connect.sid session cookie from Substack. Store it securely and provide it as the SUBSTACK_SID environment variable (or equivalent in your secrets manager).
This is the connect.sid cookie. Valid for months unless you sign out of Substack in Chrome. To rotate: sign out of Substack → sign back in → open DevTools → copy substack.sid cookie value → update your secrets store.
The publisher module at publishers/substack.py handles auth automatically. Always use it rather than calling the API directly.
ActionMethodEndpointCreate draftPOST``/api/v1/draftsPublish draftPOST``/api/v1/drafts/{id}/publishUpdate existing postPUT``/api/v1/drafts/{id}Fetch post by slugGET``/api/v1/posts/{slug}List postsGET``/api/v1/posts?limit=N
Key discovery (2026-03-20): PUT /api/v1/drafts/{id} works on already-published posts too — it edits them in place. The post ID is the same as the draft ID used to create it.
Does NOT exist: PUT /api/v1/posts/{id} returns 404. Always use the /drafts/{id} endpoint even for published posts.
Substack uses ProseMirror JSON for post bodies. The publisher converts plain text → ProseMirror automatically.
Input format: Plain text with double-newline paragraph breaks.
Output format (internal): ProseMirror doc object, serialized as a JSON string and passed as draft_body.
python
def _build_prosemirror_doc(body: str) -> dict:
paragraphs = [p.strip() for p in body.strip().split("\n\n") if p.strip()]
return {
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": p}]}
for p in paragraphs
]
}
Limitation: This produces plain paragraphs only. Bold, headers, lists, links require richer ProseMirror nodes — not yet implemented.
python
from publishers.substack import publish_substack
url = publish_substack(
title="Your Post Title",
body="First paragraph.\n\nSecond paragraph.",
publish=True # False = save as draft only
)
Or via CLI from the pipeline directory:
bash
cd ~/Documents/Codex/Content/ap-content-pipeline
python3 publishers/substack.py "Title Here" "Body paragraph one.\n\nParagraph two."
Need the numeric post ID. Get it by fetching the post:
bash
curl -s -b "substack.sid=$SUBSTACK_SID" \
"https://alternativepartners.substack.com/api/v1/posts/{slug}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('id:', d.get('id'))"
Then update:
python
from publishers.substack import update_substack
url = update_substack(
post_id=191631753,
title="Updated Title",
body="New body content.\n\nSecond paragraph."
)
The slug is the last segment of the Substack URL:
https://alternativepartners.substack.com/p/the-revops-ai-reality-check-nobodys
→ slug = the-revops-ai-reality-check-nobodys
bash
curl -s -b "substack.sid=$SUBSTACK_SID" \
"https://alternativepartners.substack.com/api/v1/posts/THE-SLUG-HERE" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('id'))"
python
url = publish_substack(title, body, publish=False)
# Returns: https://alternativepartners.substack.com/publish/post/{id}
publish endpoint is called with {"send_email": False} — posts go live on the web but do not trigger a subscriber email blast. This is intentional for automated/pipeline posts.
To send an email blast, Benjamin needs to manually click "Send" in the Substack editor UI. Do not change send_email to True without explicit confirmation.
The AP Content Pipeline at ~/Documents/Codex/Content/ap-content-pipeline/ handles end-to-end publishing including veto window, soft-veto Slack notification, and scheduling. For single one-off posts, call the publisher directly. For managed pipeline runs, use publish_runner.py.
The pipeline also has a research_gate.py that runs a web search competitive sweep + LLM differentiation analysis before drafting. Posts in idea_inbox.json with research_status: "pending" will be researched before drafting. Requires a search API key configured in your environment.
SymptomLikely causeFix401 UnauthorizedCookie expiredRotate: sign out/in of Substack in Chrome, grab new substack.sid, update your secrets storePUT /api/v1/posts/... → 404Wrong endpointUse /api/v1/drafts/{id} for updates, not /api/v1/posts/{id}``POST /api/v1/drafts/{id}/publish failsPost already publishedThat's OK — post is already live, return the known URLBody renders as one giant paragraphMissing double-newlinesInput body must use \n\n between paragraphssubstack.sid not foundCookie env var not setEnsure SUBSTACK_SID is set in your environment before running