sugarforever/publish-substack-article
Publish Markdown articles to Substack as drafts. Use when user wants to publish a Markdown file to Substack, or mentions "发布到 Substack", "Substack article", "publish to Substack". Handles Markdown-to-HTML conversion and saves as draft (never auto-publish).
npx skills add https://github.com/sugarforever/01coder-agent-skills --skill publish-substack-article
Publish Markdown content to Substack post editor, converting Markdown to HTML and pasting as rich text. Saves as draft for user review before publishing.
mcp__chrome-devtools__*)mcp__playwright__*)markdown package (pip install markdown)copy_to_clipboard.py script (shared from publish-zsxq-article skill)This skill works with both Chrome DevTools MCP and Playwright MCP:
| Action | Chrome DevTools MCP | Playwright MCP |
|--------|---------------------|----------------|
| Navigate | navigate_page | browser_navigate |
| Take snapshot | take_snapshot | browser_snapshot |
| Take screenshot | take_screenshot | browser_take_screenshot |
| Click element | click | browser_click |
| Fill text | fill | browser_type |
| Upload file | upload_file | browser_file_upload |
| Press key | press_key | browser_press_key |
| Evaluate JS | evaluate_script | browser_evaluate |
Priority: Default to Playwright MCP. Use Chrome DevTools MCP only when Playwright MCP is unavailable.
Detection: At runtime, prefer mcp__playwright__browser_navigate. Fall back to mcp__chrome-devtools__navigate_page only if Playwright tools are not available.
https://{publication}.substack.com/publishhttps://{publication}.substack.com/publish/post/{postId}The Substack publication subdomain (e.g., verysmallwoods in verysmallwoods.substack.com) is required to navigate to the dashboard. Do not hardcode a default.
Before any navigation, resolve the publication in this order:
SUBSTACK_PUBLICATION (optional, for users who publish repeatedly to the same publication).> 请提供 Substack publication subdomain(例如 verysmallwoods.substack.com 里的 verysmallwoods)。
Do not proceed to Step 3 (Navigate) without a resolved publication subdomain.
The Substack post editor uses Tiptap (ProseMirror-based WYSIWYG editor).
textbox "title" (placeholder: "Title")textbox "Add a subtitle…".ProseMirror (Tiptap editor, "Start writing...")button "Saved" (auto-saves)button "Preview"button "Continue" (publish flow - DO NOT USE)button "Settings" (title, description, thumbnail)When "Settings" or "File Settings" is open:
textbox "Add a title..."textbox "Add a description..."Bold, Italic, Strikethrough, Code, Link, Image, Audio, Video, Quote, Lists (bullet/ordered), Button, More (Code block, Divider, Footnote, LaTeX, etc.)
CRITICAL: Use clipboard paste with HTML content, NOT direct fill or plain Markdown paste.
The Tiptap editor handles HTML paste natively and renders it as rich content. The workflow is:
markdown librarycopy_to_clipboard.py htmlWhy HTML paste?
fill tool → Content treated as plain text, no formattingKnown limitation: Substack's editor does NOT support HTML tables. Tables will be collapsed into plain text. See Step 0: Pre-Processing for converting tables to images.
Substack does NOT render HTML tables. They collapse into plain text. Any Markdown table must be converted to a PNG image and uploaded separately.
Workflow:
| forming table structure)# Extract table to temp file
cat > /tmp/table1.md << 'TABLE_EOF'
| Column 1 | Column 2 | Column 3 |
|----------|----------|----------|
| Data 1 | Data 2 | Data 3 |
TABLE_EOF
# Convert via diagramless.xyz API (auto-detects as table)
node ~/.claude/skills/diagram-to-image/scripts/diagram-to-image.mjs /tmp/table1.md -o /tmp/table1.png
Image upload happens after pasting the main content — see Step 7.
Read the Markdown file and extract:
title field, or H1 header # Title, or filenameexcerpt or description fieldUse Python's markdown library with tables and fenced_code extensions:
import markdown
import re
with open('/path/to/article.md', 'r') as f:
content = f.read()
# Strip YAML frontmatter
content = re.sub(r'^---\n.*?\n---\n', '', content, flags=re.DOTALL)
# Strip cross-reference links (e.g., English version link)
# Adjust pattern as needed for your articles
content = re.sub(r'^> .* available at.*\n\n?', '', content, flags=re.MULTILINE)
# Convert to HTML
html = markdown.markdown(content, extensions=['tables', 'fenced_code'])
# Write to temp file
with open('/tmp/substack_article.html', 'w') as f:
f.write(html)
IMPORTANT: Do NOT use nl2br extension - it converts single newlines to <br> tags, causing extra line breaks in the editor.
Navigate to the Substack dashboard and create a new post:
# Navigate to Substack dashboard
navigate to: https://{publication}.substack.com/publish
If not logged in, prompt user to log in:
请先登录 Substack,登录完成后告诉我。
Please log in to Substack first, then let me know.
From the dashboard, create a new text post:
Alternatively, if the editor is already open with an empty post, proceed directly.
textbox "title")textbox "Add a subtitle…")click: title textbox
fill/type: article title
click: subtitle textbox
fill/type: article subtitle
CRITICAL: Do NOT use fill tool - it inserts plain text without formatting.
python3 /path/to/copy_to_clipboard.py html --file /tmp/substack_article.html
.ProseMirror or paragraph element inside it)press_key: Meta+v (macOS)
press_key: Control+v (Windows/Linux)
This triggers Tiptap's HTML paste handler, which renders the content as rich text with proper formatting.
If the article had tables converted to images in Step 0, insert them now:
button "Image") — a dropdown menu appears with options: Image, Gallery, Stock photos, Generate imagebrowser_file_upload with the image pathupload_file with the image pathImportant notes:
/tmp/, copy it to the project directory firstAfter pasting:
The editor auto-saves, so no explicit save action is needed.
草稿已保存到 Substack。请在 Substack 中预览并手动发布。
Draft saved to Substack. Please preview and publish manually.
Post URL: https://{publication}.substack.com/publish/post/{postId}
User: "把 /path/to/my-article.md 发布到 Substack"
0. Pre-process tables (if any)
- Detect Markdown tables
- Create styled HTML for each table
- Render to screenshots (open in browser, screenshot, close tab)
- Remove table Markdown from content
- Note insertion positions
1. Read /path/to/my-article.md
- Extract title from frontmatter or H1
- Extract subtitle from frontmatter excerpt
- Get full Markdown content (with tables removed)
2. Convert Markdown to HTML
- Strip frontmatter
- Use markdown.markdown() with ['tables', 'fenced_code']
- Write to /tmp/substack_article.html
3. Navigate to Substack dashboard or new post
4. Check if logged in
- If not, prompt user to login
5. Fill title and subtitle
6. Copy HTML to clipboard + Paste
- python3 copy_to_clipboard.py html --file /tmp/substack_article.html
- Click editor content area
- Press Cmd+V
7. Insert table images at correct positions
- For each table: click position → Image button → Image menuitem → file upload
8. Verify draft saved
- Check "Saved" status
9. Report success
- "草稿已保存,请手动预览并发布"
nl2br extension - Causes double line breaks10. Resolve publication first - Never hardcode a publication subdomain. Resolve via skill argument, SUBSTACK_PUBLICATION env var, or user prompt before any navigation (see Publication Resolution)
11. Prefer Playwright MCP - Default to Playwright MCP; only use Chrome DevTools MCP when Playwright is unavailable
If you see raw HTML tags or unformatted text:
fill tool instead of clipboard pastecopy_to_clipboard.py + Cmd+V method (see Step 6)Substack's Tiptap editor does not support HTML tables. They collapse into inline plain text.
If page shows login prompt:
请先登录 Substack: https://{publication}.substack.com
登录完成后告诉我。
If editor elements are not visible:
If copy_to_clipboard.py fails:
pip install pyobjc-framework-Cocoa (macOS)| Element | Selector/Identifier | Description |
|---------|---------------------|-------------|
| Title input | textbox "title" | Post title |
| Subtitle input | textbox "Add a subtitle…" | Post subtitle |
| Content area | .ProseMirror (Tiptap editor) | Post content |
| Save status | button "Saved" | Auto-save indicator |
| Preview button | button "Preview" | Preview post |
| Continue button | button "Continue" | DO NOT USE - starts publish flow |
| Settings button | button "Settings" | Open settings sidebar |
| Exit button | button "Exit" | Exit editor |
| Image button | button "Image" | Opens image upload dropdown |
| Image menuitem | menuitem "Image" | Opens file chooser for image upload |
| Author button | button "{PublicationName}" | Author/publication selector |
Markdown file
↓ (Python markdown library)
HTML string
↓ (copy_to_clipboard.py)
System clipboard (text/html + text/plain)
↓ (Cmd+V keyboard shortcut)
Tiptap ProseMirror editor
↓ (auto-save)
Substack draft
The following Markdown elements are correctly rendered after HTML conversion and paste:
| Markdown Element | Substack Support | Notes |
|-----------------|-----------------|-------|
| Headings (H2-H6) | Yes | H1 not recommended (title is separate) |
| Bold / Italic | Yes | |
| Inline code | Yes | |
| Code blocks | Yes | Syntax highlighting may vary |
| Links | Yes | |
| Blockquotes | Yes | |
| Bullet lists | Yes | |
| Ordered lists | Yes | |
| Horizontal rules | Yes | |
| Tables | No → Image | Convert via diagram-to-image skill, upload as image |
| Images | Manual | Upload via Image toolbar button → file chooser |
Take sugarforever/publish-substack-article from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip.
Without those the skill loads but fails at the first command.