back to posts
#42 Part 7 2026-01-13 12 min

AI-Powered Cross-Posting: Publishing to Substack While I Drink Coffee

Using browser automation to cross-post from Astro to Substack without copy-paste hell

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:

  1. Copy content from markdown file
  2. Paste into Substack’s editor
  3. Watch formatting break
  4. Manually fix headers, code blocks, tables
  5. Upload cover image
  6. Add subtitle, tags, settings
  7. 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:

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:

ToolPurpose
navigateGo to a URL
computerClick, type, scroll, screenshot
findLocate elements by text
read_pageGet page content and element refs
form_inputFill 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:

What doesn’t work:

The solution is to:

  1. Copy our HTML to clipboard
  2. Focus the editor
  3. Cmd+V to paste
  4. 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:

IssueSymptomFix
Auth expiredLogin page appearsRe-login, restart flow
Element movedClick hits wrong thingRe-read page, re-find
Upload stuckProgress bar frozenRefresh, retry
Editor reformatsContent looks wrongManual adjustment
Rate limitedActions failWait, 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

StepManualAutomated
Open Substack5 sec2 sec
Create new post10 sec3 sec
Paste & format content5-10 min30 sec
Upload image30 sec10 sec
Configure settings30 sec5 sec
Publish10 sec5 sec
Total6-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:

  1. Generate task instructions (post list, prompts, steps)
  2. Copy to clipboard
  3. Paste into Perplexity sidebar
  4. 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:

Coming soon:

Would be nice:


Summary

ComponentTechnology
Source formatMarkdown
Conversionmarked.js
Browser controlClaude-in-Chrome MCP
File picker hackAppleScript
Error handlingScreenshot + retry
Human fallbackWhen 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.