AI-Powered Cross-Posting: Publishing to Substack While I Drink Coffee
I write once in markdown. AI handles copying to Substack, formatting the content, uploading images, and clicking publish. Here’s how.
The Problem: Copy-Paste Hell
Substack doesn’t have a markdown import. Every post requires:
- Copy content from markdown file
- Paste into Substack’s editor
- Watch formatting break
- Manually fix headers, code blocks, tables
- Upload cover image
- Add subtitle, tags, settings
- Click through publish dialogs
For one post? Annoying but doable. For three posts a week? Unsustainable. For a backlog of 40 posts? I’d rather not write at all.
The Solution: Browser Automation
Claude Code can control a browser through the Claude-in-Chrome MCP. It can:
- Navigate to URLs
- Click elements
- Type text
- Upload files
- Read page content
- Take screenshots
Combined with AI judgment about what to click and when, it becomes a publishing assistant.
The Workflow
/publish-post
│
▼
┌─────────────────────────────────────┐
│ 1. VALIDATE │
│ - Check frontmatter │
│ - Verify build succeeds │
│ - Confirm post exists │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. DEPLOY TO BLOG │
│ - Git commit │
│ - Git push │
│ - Cloudflare Pages builds │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3. OPEN SUBSTACK │
│ - Navigate to editor │
│ - Start new post │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 4. PASTE CONTENT │
│ - Copy converted HTML │
│ - Paste into editor │
│ - Fix any formatting issues │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 5. UPLOAD IMAGE │
│ - Click image button │
│ - Navigate file picker │
│ - Select cover image │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 6. CONFIGURE & PUBLISH │
│ - Set audience (Everyone) │
│ - Click Continue │
│ - Click Publish │
└─────────────────────────────────────┘
The Export Script
Before browser automation, we need clean HTML. Substack’s editor mangles raw markdown but accepts HTML.
// scripts/substack-export.js
import { marked } from 'marked';
import matter from 'gray-matter';
import fs from 'fs';
function convertPost(markdownPath) {
const raw = fs.readFileSync(markdownPath, 'utf-8');
const { data: frontmatter, content } = matter(raw);
// Convert markdown to HTML
const html = marked(content, {
gfm: true, // GitHub Flavored Markdown
breaks: false, // Don't convert \n to <br>
headerIds: false, // Substack handles its own IDs
});
// Write to substack-exports folder
const outputPath = `substack-exports/${frontmatter.title}.html`;
fs.writeFileSync(outputPath, html);
return { frontmatter, html, outputPath };
}
Run it:
npm run substack # Convert latest post
npm run substack:all # Convert all posts
Browser Automation with Claude-in-Chrome
The Claude-in-Chrome MCP exposes browser control through tool calls:
| Tool | Purpose |
|---|---|
navigate | Go to a URL |
computer | Click, type, scroll, screenshot |
find | Locate elements by text |
read_page | Get page content and element refs |
form_input | Fill form fields |
Example: Clicking the New Post Button
// Find the "New post" button
const result = await mcp.find({
query: "New post",
tabId: currentTab
});
// Click it
await mcp.computer({
action: "left_click",
ref: result.ref,
tabId: currentTab
});
The find tool returns element references that computer can interact with. AI figures out which element to click based on context.
Handling the File Picker
Here’s where it gets tricky. Browser file pickers are OS-level dialogs - you can’t click inside them with browser automation.
The workaround: AppleScript
osascript -e '
tell application "System Events"
keystroke "g" using {command down, shift down} -- Go to folder
delay 0.5
keystroke "/path/to/image.png"
keystroke return
delay 0.5
keystroke return -- Select file
end tell
'
This types the file path into the native file picker dialog. Hacky? Yes. Works? Also yes.
Handling Substack’s Editor (ProseMirror)
Substack uses ProseMirror, a rich text editor. It doesn’t accept plain paste - it interprets clipboard content.
What works:
- Pasting HTML (converts to rich text)
- Typing directly
- Using editor toolbar buttons
What doesn’t work:
- Pasting raw markdown
- Programmatic innerHTML injection
- Most “paste as plain text” approaches
The solution is to:
- Copy our HTML to clipboard
- Focus the editor
- Cmd+V to paste
- Let ProseMirror convert it
// Copy HTML to clipboard
await exec(`echo '${html}' | pbcopy`);
// Focus editor
await mcp.computer({
action: "left_click",
ref: editorRef,
tabId: tab
});
// Paste
await mcp.computer({
action: "key",
text: "cmd+v",
tabId: tab
});
The Complete Flow (Annotated)
1. AI reads the markdown post
→ Extracts title, description, tags
→ Notes the cover image path
2. AI opens Substack dashboard
→ Navigates to: myronkoch.substack.com/publish
3. AI clicks "New post"
→ Waits for editor to load
→ Takes screenshot to verify
4. AI pastes the title
→ Finds title field
→ Types the post title
5. AI pastes the content
→ Copies HTML to clipboard
→ Focuses editor body
→ Cmd+V to paste
→ Takes screenshot to verify formatting
6. AI adds cover image
→ Clicks "Add image" in header
→ Uses AppleScript for file picker
→ Waits for upload
→ Verifies image appears
7. AI configures settings
→ Sets subtitle from description
→ Verifies "Everyone" audience
→ Unchecks "Send to subscribers" (for silent drops)
8. AI publishes
→ Clicks "Continue"
→ Clicks "Publish now"
→ Captures the published URL
→ Reports success
Error Handling
Browser automation is brittle. Elements move, dialogs appear unexpectedly, uploads fail. The AI handles this through:
Screenshot verification: After each action, take a screenshot. If something looks wrong, try again or ask for help.
Element re-finding: If a click fails, re-read the page and find the element again. DOM changes between actions.
Timeout recovery: If an action takes too long (upload stuck), screenshot the state and decide whether to retry or abort.
Human fallback: Some issues (CAPTCHAs, auth expiry) require human intervention. The AI recognizes these and asks for help rather than spinning forever.
When Automation Breaks
It will break. Common causes:
| Issue | Symptom | Fix |
|---|---|---|
| Auth expired | Login page appears | Re-login, restart flow |
| Element moved | Click hits wrong thing | Re-read page, re-find |
| Upload stuck | Progress bar frozen | Refresh, retry |
| Editor reformats | Content looks wrong | Manual adjustment |
| Rate limited | Actions fail | Wait, retry slower |
The key is graceful degradation. Automation handles 90% of the work. The remaining 10% might need a human touch.
Comparing Manual vs. Automated
| Step | Manual | Automated |
|---|---|---|
| Open Substack | 5 sec | 2 sec |
| Create new post | 10 sec | 3 sec |
| Paste & format content | 5-10 min | 30 sec |
| Upload image | 30 sec | 10 sec |
| Configure settings | 30 sec | 5 sec |
| Publish | 10 sec | 5 sec |
| Total | 6-11 min | ~1 min |
For a single post, 5-10 minutes saved. For 40 posts, that’s 3-6 hours saved. For ongoing publishing at 2x/week, that’s hours per month.
The Perplexity Agent Discovery
During bulk image generation for Substack posts, we discovered something useful:
Perplexity’s Comet agent excels at browser automation tasks.
The workflow (screenshots, element finding, clicking, typing) maps perfectly to Perplexity’s capabilities. For bulk operations like “add images to 17 posts,” handing off to Perplexity is more token-efficient than running it through Claude Code.
The handoff pattern:
- Generate task instructions (post list, prompts, steps)
- Copy to clipboard
- Paste into Perplexity sidebar
- Let it execute
This is documented in our Substack Images skill for future reference.
Code Organization
The cross-posting logic lives in:
~/.claude/skills/substack-images/SKILL.md # Image generation workflow
~/.claude/commands/publish-post/ # Full publish command
blog-site/scripts/substack-export.js # Markdown → HTML conversion
The skill files document the exact steps, element names, and gotchas. When the workflow changes (Substack updates their UI), we update the skill.
Future Improvements
Currently working:
- Manual trigger via
/publish-post - Browser automation for cross-posting
- Image upload via AppleScript hack
Coming soon:
- Scheduled publishing (write now, publish later)
- Batch operations (publish 5 posts at once)
- Draft sync (keep Substack drafts in sync with local files)
Would be nice:
- Official Substack API access (currently limited)
- Native file picker support in browser automation
- Automatic formatting verification
Summary
| Component | Technology |
|---|---|
| Source format | Markdown |
| Conversion | marked.js |
| Browser control | Claude-in-Chrome MCP |
| File picker hack | AppleScript |
| Error handling | Screenshot + retry |
| Human fallback | When automation can’t proceed |
The goal isn’t full automation. It’s reducing the boring parts so I can focus on writing.
AI handles the clicking. I handle the thinking.
Next up: The Command System - Two commands that orchestrate the entire workflow.