Substack Post
Automates publishing a local blog file (markdown) to Substack using the Playwright MCP browser tools. Defaults to saving as a draft and confirms with the user before publishing.
Prerequisites
This plugin requires the Playwright MCP server to be installed and configured.
Install browsers:
npx playwright install
Add to your Claude Code MCP config (~/.claude/claude_desktop_config.json or project-level .mcp.json):
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
You also need node on PATH. The skill installs the marked markdown→HTML library on demand into a temporary directory; no global install required.
See https://github.com/microsoft/playwright-mcp for full setup instructions.
Credentials Setup
Credentials come from environment variables. Before using this plugin, ensure these are set:
SUBSTACK_EMAIL — Substack account email
SUBSTACK_PASSWORD — Substack account password (only works if you've explicitly set one — magic-link-only accounts will fail; set a password at https://substack.com/account/login-options)
SUBSTACK_PUBLICATION (optional) — your publication subdomain, e.g. myblog for myblog.substack.com. If omitted, the skill navigates to the dashboard at substack.com after login and clicks "New post".
One-time setup:
- Create a
.socials file in the project root (add it to .gitignore)
- Add (the file may use
export KEY=VALUE form too):SUBSTACK_EMAIL=your_email@example.com
SUBSTACK_PASSWORD=your_password
SUBSTACK_PUBLICATION=myblog
- Or export them in your shell:
export SUBSTACK_EMAIL=... SUBSTACK_PASSWORD=...
If the env vars are not set, ask the user before proceeding. Do NOT hardcode credentials anywhere.
Inputs
You need one thing from the user:
- Blog file path — local path to a markdown blog file. Required.
Optional:
- Title — defaults to the first
# H1 line of the file, falling back to the filename without extension.
- Subtitle — Substack post subtitle.
- Action —
draft (default) or publish. Always default to draft and confirm with the user before publishing.
If the file path is unclear, ask the user before proceeding.
How content gets into Substack — the working approach
Substack's editor is a TipTap/ProseMirror instance. Three things matter:
- It accepts
text/html from the system clipboard. A ClipboardItem containing a text/html Blob, written via navigator.clipboard.write([item]) and then pasted with Ctrl/Cmd+V, gets parsed by ProseMirror's clipboard handler — headings, bold/italic, links, ordered/unordered lists, fenced code blocks, horizontal rules and inline <code> all survive.
- It does NOT support tables. ProseMirror strips
<table> entirely on paste. The "More" menu has Code block, Divider, Footnote, LaTeX, Poetry, Poll, Prediction market, Recipe — no table item. The only reliable way to ship a table is to render it as a PNG and paste the PNG.
- It accepts pasted images. A
ClipboardItem with an image/png Blob, pasted with Ctrl/Cmd+V, gets uploaded to substackcdn.com automatically.
Synthetic ClipboardEvent dispatch with dataTransfer does not work — ProseMirror only honors paste events that come from a real Ctrl/Cmd+V after navigator.clipboard.write. Plan accordingly.
The pipeline therefore is:
markdown → strip frontmatter → marked → HTML
→ split out <table> blocks (replace each with a unique placeholder paragraph)
→ write HTML to clipboard, Ctrl+V into editor (gives most of the post)
→ for each table: render PNG, copy PNG to clipboard, position cursor at the
placeholder, Ctrl+V (image uploads to Substack CDN)
Workflow
Step 1: Read credentials
Read SUBSTACK_EMAIL and SUBSTACK_PASSWORD (and optionally SUBSTACK_PUBLICATION) from environment:
echo $SUBSTACK_EMAIL
echo $SUBSTACK_PASSWORD
echo $SUBSTACK_PUBLICATION
If blank, check for a .socials file (note the export prefix variant):
grep -E "^(export )?SUBSTACK_(EMAIL|PASSWORD|PUBLICATION)=" .socials 2>/dev/null
If still not found, ask the user to provide them. To load a .socials file with export lines into the environment:
set -a; source .socials; set +a
Step 2: Read and parse the blog file
- Use the Read tool on the blog file path.
- Detect and strip frontmatter. If the file starts with a YAML frontmatter block (
--- ... ---) or a TOML block (+++ ... +++), parse it and remove the entire block from the body before HTML conversion. This handles Hugo, Jekyll, Zola, Hexo, Astro, Obsidian, etc. Files with no frontmatter are passed through unchanged.
- Resolve title in this order — first non-empty wins:
- Explicit title from the user
title field in the frontmatter
- First
# H1 line in the body (then strip that H1 from the body)
- Filename without extension (skip generic names like
index, README, post and use the parent directory name instead — e.g. posts/gpu-util/index.md → gpu-util)
- Resolve subtitle in this order:
- Explicit subtitle from the user
subtitle field in the frontmatter
description / summary / excerpt field in the frontmatter
- Otherwise leave blank
- The remaining markdown (frontmatter stripped, leading H1 stripped if it was used as the title) is the post body.
Step 3: Convert markdown to HTML and split out tables
In a tmp working directory (e.g. /tmp/substack-post-<timestamp>):
- Install
marked if not already there:mkdir -p /tmp/substack-post-$$ && cd /tmp/substack-post-$$
npm i marked --silent
- Write a tiny converter script (
md2html.mjs):import { readFileSync } from 'fs';
import { marked } from 'marked';
const md = readFileSync(process.argv[2], 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');
console.log(marked.parse(md));
- Run it on the body file (or write the body to disk first):
node md2html.mjs body.md > body.html
- Split out tables. Use a small node script to walk the HTML, extract every
<table>...</table> block, and replace each with a unique placeholder paragraph like <p data-substack-table="N"></p>. Save the extracted tables (keyed by index) so they can be rendered to PNG in Step 5.
- Optionally prepend a canonical-link header if the user requested one (e.g. "READ THE FULL ARTICLE AT https://example.com/post/"). Insert it as
<p><strong>READ FULL ARTICLE AT </strong><a href="...">...</a></p><hr> at the top.
Step 4: Log in to Substack and open the editor
- Navigate to
https://substack.com/sign-in.
- If you're already signed in (Substack redirects you to substack.com home), skip the rest of this step.
- Click the "Sign in with password" link. Substack defaults to magic-link sign-in; without this click the password field never appears.
- Fill
SUBSTACK_EMAIL and SUBSTACK_PASSWORD, submit.
- If
SUBSTACK_PUBLICATION is set, navigate to https://<publication>.substack.com/publish/post?type=newsletter. This auto-creates a new draft and redirects to /publish/post/<id>. Otherwise navigate to https://substack.com and click "New post" on the dashboard.
If a captcha or device-verification page appears, stop and ask the user to complete it manually in the same browser session, then retry.
Step 5: Fill title, subtitle, and body
Title. Click the [data-testid="post-title"] textarea (or getByRole('textbox', { name: 'Add a title…' })) and type the title via browser_type. After typing, the page title updates to the post title — that's your verification.
Subtitle. Click getByRole('textbox', { name: 'Add a subtitle…' }) and type the subtitle.
Body — write HTML to clipboard, then paste:
In the Substack tab, run a browser_evaluate that puts the body HTML on the clipboard:
async () => {
const html = `__BODY_HTML_GOES_HERE__`; // includes table-placeholder paragraphs
const plain = html.replace(/<[^>]+>/g, '');
const item = new ClipboardItem({
'text/html': new Blob([html], {type: 'text/html'}),
'text/plain': new Blob([plain], {type: 'text/plain'})
});
await navigator.clipboard.write([item]);
return { ok: true, len: html.length };
}
Then click the editor (getByTestId('editor')) and press ControlOrMeta+v ONCE.
Verify only one paste happened by counting headings/children.length afterwards. A double-paste produces interleaved duplicate content. If you see duplication, do Ctrl/Cmd+A followed by Delete to clear, then paste once more.
The browser_evaluate function parameter accepts ~20 KB of inline source comfortably; embed the HTML literal directly. For very long posts (>30 KB), serve the HTML from a temporary localhost HTTP server (see Step 6) and fetch() it inside the evaluate.
Verify rendering. Snapshot the editor and check that headings, bold spans, lists, links, and any code blocks have proper structure. Tables will be missing — that's expected, the placeholders are still there.
Step 6: Render and insert each table as a PNG
This is the only reliable way to put tables into Substack. The pattern:
Build a single HTML page that contains every table in tables.html under a tmp dir, each wrapped in <div id="t1">…</div>, <div id="t2">…</div>, etc. Style it with a clean white background and tabular-nums so it renders sharply. Example styles:
body { margin: 0; padding: 24px; background: white;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
.wrap { display: inline-block; padding: 16px; background: white; }
h3 { margin: 0 0 12px; font-size: 18px; }
table { border-collapse: collapse; font-size: 14px; }
th, td { padding: 8px 14px; text-align: left; }
thead tr { border-bottom: 2px solid #888; }
tbody tr { border-bottom: 1px solid #e6e6e6; }
tbody tr:last-child { border-bottom: 1px solid #888; }
td { font-variant-numeric: tabular-nums; }
Start a local HTTP server rooted in the tmp dir. The browser tab can't read file:// URLs, but http://127.0.0.1:<port> works:
cd /tmp/substack-post-$$ && python3 -m http.server 8765 > srv.log 2>&1 & echo $! > srv.pid
Open a new browser tab to http://127.0.0.1:8765/tables.html (use browser_tabs action new).
Screenshot each table element with browser_take_screenshot targeting #t1, #t2, …, saving as table1.png, table2.png, … (the screenshot file is written under the playwright-mcp working dir; or save to the tmp HTTP-served dir directly).
For each table N:
- Switch to the localhost tab (
browser_tabs action select).
- In a
browser_evaluate, fetch the PNG and write to clipboard:async () => {
const r = await fetch('/tableN.png');
const blob = await r.blob();
await navigator.clipboard.write([new ClipboardItem({'image/png': blob})]);
return { ok: true, size: blob.size };
}
- Switch back to the Substack tab.
- In a
browser_evaluate, find the placeholder paragraph for table N and place the cursor inside it:() => {
const editor = document.querySelector('.ProseMirror');
editor.focus();
const placeholder = editor.querySelector('[data-substack-table="N"]')
|| /* fallback: find empty <p> right after a known H3 heading */;
const range = document.createRange();
range.setStart(placeholder, 0);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
Note: ProseMirror often strips unknown data-* attributes during paste. If your placeholder attribute is gone, locate the insertion point by the heading that preceded the table (e.g. the empty <p> immediately after the H3 with the table's title).
- Press
ControlOrMeta+v. Wait ~2 s for Substack to upload the image; the <img> will appear with src="https://substackcdn.com/image/fetch/…".
Stop the local HTTP server:
kill $(cat /tmp/substack-post-$$/srv.pid)
Step 7: Save as draft
- Substack autosaves continuously. Wait a few seconds and verify the toolbar shows a "Saved" status button.
- Capture the draft URL from
location.href (it'll be https://<publication>.substack.com/publish/post/<id>).
Step 8: Confirm before publishing
By default, stop here. Report to the user:
- The draft URL.
- Each PNG that was uploaded for a table (so they can re-render or replace if styling is off).
- Any remaining markdown elements that didn't convert cleanly (currently: relative-path images like
./foo.png — those need manual upload because the source PNGs aren't reachable from Substack).
- Ask: "Draft saved. Want me to click Publish?"
Only if the user explicitly asked to publish (in their original request or in reply), continue:
- Click "Continue".
- On the audience step, choose "Send to everyone now" (or whatever the user specified).
- Click the final publish/send button.
- Take a snapshot to verify the post is live.
- Report the public post URL.
Error Handling
- "Sign in with password" link not found: the page layout may have changed — take a screenshot and report.
- Password login fails / "no password set": tell the user to set a password at
https://substack.com/account/login-options and retry.
- 2FA / captcha / device-verification page: stop, ask the user to complete the challenge in the same browser session, then retry.
- File not found / unreadable: stop and report the path.
- Editor element not found: take a screenshot and report what the page shows.
- Rate limit on login: Substack may block rapid retries — tell the user to wait a few minutes.
- Paste produced double content: don't try to surgically delete duplicates —
Ctrl/Cmd+A, Delete, then paste once more. Manual deletion can cascade and lose unrelated paragraphs (Substack's Shift+End selects across more than one visual line in long paragraphs).
- Table image didn't insert: confirm the localhost server is still running (
curl -I http://127.0.0.1:8765/tableN.png), confirm the clipboard write succeeded (look for ok: true), and confirm the cursor is inside the editor (not in a textarea like the title). Re-run the localhost-tab clipboard.write then Ctrl/Cmd+V in the Substack tab.
Important Notes
- Markdown fidelity is high via the HTML clipboard route. Headings, bold/italic, links, ordered/unordered lists, fenced code blocks, horizontal rules, and inline
<code> all render correctly when you paste text/html. Tables require the screenshot detour because Substack has no table primitive. Images with relative paths (./foo.png) won't work — Substack can't reach the local file. If the post needs them, either upload manually after the draft is saved, or upload them to a public host (S3, the user's blog CDN, GitHub raw) and rewrite the src to an absolute URL before pasting.
- Synthetic clipboard events do not work. Don't try
editor.dispatchEvent(new ClipboardEvent('paste', { clipboardData })) — ProseMirror ignores it. Always use navigator.clipboard.write followed by a real Ctrl/Cmd+V keypress.
- Drafts are private. This skill stops at "Save draft" by default. Nothing is sent to subscribers until Publish is confirmed.
- Magic-link-only accounts won't work. Password login requires the user to have explicitly set a password at substack.com/account/login-options.
- Don't hardcode credentials. Always read from env vars or
.socials; never paste credentials into code or chat.
- Clean up the tmp working dir at the end —
rm -rf /tmp/substack-post-$$ — and stop the HTTP server.
1---2name: substack-post-23description: Publishes a local blog file to Substack using Playwright MCP. Trigger when the user says anything like "publish to Substack", "post this blog to Substack", "upload my markdown to Substack", "share on Substack", "save this draft to Substack", or any similar intent to send a local blog file to Substack.4---56# Substack Post78Automates publishing a local blog file (markdown) to Substack using the Playwright MCP browser tools. Defaults to saving as a draft and confirms with the user before publishing.910## Prerequisites1112This plugin requires the **Playwright MCP** server to be installed and configured.1314Install browsers:15```bash16npx playwright install17```1819Add to your Claude Code MCP config (`~/.claude/claude_desktop_config.json` or project-level `.mcp.json`):20```json21{22 "mcpServers": {23 "playwright": {24 "command": "npx",25 "args": ["@playwright/mcp@latest"]26 }27 }28}29```3031You also need **node** on PATH. The skill installs the `marked` markdown→HTML library on demand into a temporary directory; no global install required.3233See https://github.com/microsoft/playwright-mcp for full setup instructions.3435## Credentials Setup3637Credentials come from environment variables. Before using this plugin, ensure these are set:3839- `SUBSTACK_EMAIL` — Substack account email40- `SUBSTACK_PASSWORD` — Substack account password (only works if you've explicitly set one — magic-link-only accounts will fail; set a password at https://substack.com/account/login-options)41- `SUBSTACK_PUBLICATION` *(optional)* — your publication subdomain, e.g. `myblog` for `myblog.substack.com`. If omitted, the skill navigates to the dashboard at substack.com after login and clicks "New post".4243**One-time setup:**441. Create a `.socials` file in the project root (add it to `.gitignore`)452. Add (the file may use `export KEY=VALUE` form too):46 ```47 SUBSTACK_EMAIL=your_email@example.com48 SUBSTACK_PASSWORD=your_password49 SUBSTACK_PUBLICATION=myblog50 ```513. Or export them in your shell: `export SUBSTACK_EMAIL=... SUBSTACK_PASSWORD=...`5253If the env vars are not set, ask the user before proceeding. Do NOT hardcode credentials anywhere.5455## Inputs5657You need one thing from the user:58- **Blog file path** — local path to a markdown blog file. Required.5960Optional:61- **Title** — defaults to the first `# H1` line of the file, falling back to the filename without extension.62- **Subtitle** — Substack post subtitle.63- **Action** — `draft` (default) or `publish`. Always default to draft and confirm with the user before publishing.6465If the file path is unclear, ask the user before proceeding.6667## How content gets into Substack — the working approach6869Substack's editor is a TipTap/ProseMirror instance. Three things matter:70711. **It accepts `text/html` from the system clipboard.** A `ClipboardItem` containing a `text/html` Blob, written via `navigator.clipboard.write([item])` and then pasted with `Ctrl/Cmd+V`, gets parsed by ProseMirror's clipboard handler — headings, bold/italic, links, ordered/unordered lists, fenced code blocks, horizontal rules and inline `<code>` all survive.722. **It does NOT support tables.** ProseMirror strips `<table>` entirely on paste. The "More" menu has Code block, Divider, Footnote, LaTeX, Poetry, Poll, Prediction market, Recipe — no table item. The only reliable way to ship a table is to render it as a PNG and paste the PNG.733. **It accepts pasted images.** A `ClipboardItem` with an `image/png` Blob, pasted with `Ctrl/Cmd+V`, gets uploaded to substackcdn.com automatically.7475Synthetic `ClipboardEvent` dispatch with `dataTransfer` does **not** work — ProseMirror only honors paste events that come from a real `Ctrl/Cmd+V` after `navigator.clipboard.write`. Plan accordingly.7677The pipeline therefore is:7879```80markdown → strip frontmatter → marked → HTML81 → split out <table> blocks (replace each with a unique placeholder paragraph)82 → write HTML to clipboard, Ctrl+V into editor (gives most of the post)83 → for each table: render PNG, copy PNG to clipboard, position cursor at the84 placeholder, Ctrl+V (image uploads to Substack CDN)85```8687## Workflow8889### Step 1: Read credentials9091Read `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD` (and optionally `SUBSTACK_PUBLICATION`) from environment:92```bash93echo $SUBSTACK_EMAIL94echo $SUBSTACK_PASSWORD95echo $SUBSTACK_PUBLICATION96```9798If blank, check for a `.socials` file (note the `export` prefix variant):99```bash100grep -E "^(export )?SUBSTACK_(EMAIL|PASSWORD|PUBLICATION)=" .socials 2>/dev/null101```102103If still not found, ask the user to provide them. To load a `.socials` file with `export` lines into the environment:104```bash105set -a; source .socials; set +a106```107108### Step 2: Read and parse the blog file1091101. Use the Read tool on the blog file path.1112. **Detect and strip frontmatter.** If the file starts with a YAML frontmatter block (`---` ... `---`) or a TOML block (`+++` ... `+++`), parse it and remove the entire block from the body before HTML conversion. This handles Hugo, Jekyll, Zola, Hexo, Astro, Obsidian, etc. Files with no frontmatter are passed through unchanged.1123. **Resolve title** in this order — first non-empty wins:113 - Explicit title from the user114 - `title` field in the frontmatter115 - First `# H1` line in the body (then strip that H1 from the body)116 - Filename without extension (skip generic names like `index`, `README`, `post` and use the parent directory name instead — e.g. `posts/gpu-util/index.md` → `gpu-util`)1174. **Resolve subtitle** in this order:118 - Explicit subtitle from the user119 - `subtitle` field in the frontmatter120 - `description` / `summary` / `excerpt` field in the frontmatter121 - Otherwise leave blank1225. The remaining markdown (frontmatter stripped, leading H1 stripped if it was used as the title) is the post body.123124### Step 3: Convert markdown to HTML and split out tables125126In a tmp working directory (e.g. `/tmp/substack-post-<timestamp>`):1271281. Install `marked` if not already there:129 ```bash130 mkdir -p /tmp/substack-post-$$ && cd /tmp/substack-post-$$131 npm i marked --silent132 ```1332. Write a tiny converter script (`md2html.mjs`):134 ```js135 import { readFileSync } from 'fs';136 import { marked } from 'marked';137 const md = readFileSync(process.argv[2], 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');138 console.log(marked.parse(md));139 ```1403. Run it on the body file (or write the body to disk first):141 ```bash142 node md2html.mjs body.md > body.html143 ```1444. **Split out tables.** Use a small node script to walk the HTML, extract every `<table>...</table>` block, and replace each with a unique placeholder paragraph like `<p data-substack-table="N"></p>`. Save the extracted tables (keyed by index) so they can be rendered to PNG in Step 5.1455. **Optionally prepend a canonical-link header** if the user requested one (e.g. "READ THE FULL ARTICLE AT https://example.com/post/"). Insert it as `<p><strong>READ FULL ARTICLE AT </strong><a href="...">...</a></p><hr>` at the top.146147### Step 4: Log in to Substack and open the editor1481491. Navigate to `https://substack.com/sign-in`.1502. If you're already signed in (Substack redirects you to substack.com home), skip the rest of this step.1513. Click the **"Sign in with password"** link. Substack defaults to magic-link sign-in; without this click the password field never appears.1524. Fill `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD`, submit.1535. If `SUBSTACK_PUBLICATION` is set, navigate to `https://<publication>.substack.com/publish/post?type=newsletter`. This auto-creates a new draft and redirects to `/publish/post/<id>`. Otherwise navigate to `https://substack.com` and click "New post" on the dashboard.154155If a captcha or device-verification page appears, stop and ask the user to complete it manually in the same browser session, then retry.156157### Step 5: Fill title, subtitle, and body1581591. **Title.** Click the `[data-testid="post-title"]` textarea (or `getByRole('textbox', { name: 'Add a title…' })`) and type the title via `browser_type`. After typing, the page title updates to the post title — that's your verification.1602. **Subtitle.** Click `getByRole('textbox', { name: 'Add a subtitle…' })` and type the subtitle.1613. **Body — write HTML to clipboard, then paste:**162163 In the Substack tab, run a `browser_evaluate` that puts the body HTML on the clipboard:164 ```js165 async () => {166 const html = `__BODY_HTML_GOES_HERE__`; // includes table-placeholder paragraphs167 const plain = html.replace(/<[^>]+>/g, '');168 const item = new ClipboardItem({169 'text/html': new Blob([html], {type: 'text/html'}),170 'text/plain': new Blob([plain], {type: 'text/plain'})171 });172 await navigator.clipboard.write([item]);173 return { ok: true, len: html.length };174 }175 ```176177 Then click the editor (`getByTestId('editor')`) and press `ControlOrMeta+v` ONCE.178179 **Verify only one paste happened** by counting headings/`children.length` afterwards. A double-paste produces interleaved duplicate content. If you see duplication, do `Ctrl/Cmd+A` followed by `Delete` to clear, then paste once more.180181 The `browser_evaluate` `function` parameter accepts ~20 KB of inline source comfortably; embed the HTML literal directly. For very long posts (>30 KB), serve the HTML from a temporary localhost HTTP server (see Step 6) and `fetch()` it inside the evaluate.1821834. **Verify rendering.** Snapshot the editor and check that headings, bold spans, lists, links, and any code blocks have proper structure. Tables will be missing — that's expected, the placeholders are still there.184185### Step 6: Render and insert each table as a PNG186187This is the only reliable way to put tables into Substack. The pattern:1881891. **Build a single HTML page that contains every table** in `tables.html` under a tmp dir, each wrapped in `<div id="t1">…</div>`, `<div id="t2">…</div>`, etc. Style it with a clean white background and tabular-nums so it renders sharply. Example styles:190 ```css191 body { margin: 0; padding: 24px; background: white;192 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }193 .wrap { display: inline-block; padding: 16px; background: white; }194 h3 { margin: 0 0 12px; font-size: 18px; }195 table { border-collapse: collapse; font-size: 14px; }196 th, td { padding: 8px 14px; text-align: left; }197 thead tr { border-bottom: 2px solid #888; }198 tbody tr { border-bottom: 1px solid #e6e6e6; }199 tbody tr:last-child { border-bottom: 1px solid #888; }200 td { font-variant-numeric: tabular-nums; }201 ```2022. **Start a local HTTP server** rooted in the tmp dir. The browser tab can't read `file://` URLs, but `http://127.0.0.1:<port>` works:203 ```bash204 cd /tmp/substack-post-$$ && python3 -m http.server 8765 > srv.log 2>&1 & echo $! > srv.pid205 ```2063. **Open a new browser tab** to `http://127.0.0.1:8765/tables.html` (use `browser_tabs` action `new`).2074. **Screenshot each table element** with `browser_take_screenshot` targeting `#t1`, `#t2`, …, saving as `table1.png`, `table2.png`, … (the screenshot file is written under the playwright-mcp working dir; or save to the tmp HTTP-served dir directly).2085. **For each table N:**209 - **Switch to the localhost tab** (`browser_tabs` action `select`).210 - In a `browser_evaluate`, fetch the PNG and write to clipboard:211 ```js212 async () => {213 const r = await fetch('/tableN.png');214 const blob = await r.blob();215 await navigator.clipboard.write([new ClipboardItem({'image/png': blob})]);216 return { ok: true, size: blob.size };217 }218 ```219 - **Switch back to the Substack tab.**220 - In a `browser_evaluate`, find the placeholder paragraph for table N and place the cursor inside it:221 ```js222 () => {223 const editor = document.querySelector('.ProseMirror');224 editor.focus();225 const placeholder = editor.querySelector('[data-substack-table="N"]')226 || /* fallback: find empty <p> right after a known H3 heading */;227 const range = document.createRange();228 range.setStart(placeholder, 0);229 range.collapse(true);230 const sel = window.getSelection();231 sel.removeAllRanges();232 sel.addRange(range);233 }234 ```235 Note: ProseMirror often strips unknown `data-*` attributes during paste. If your placeholder attribute is gone, locate the insertion point by the heading that preceded the table (e.g. the empty `<p>` immediately after the H3 with the table's title).236 - Press `ControlOrMeta+v`. Wait ~2 s for Substack to upload the image; the `<img>` will appear with `src="https://substackcdn.com/image/fetch/…"`.2372386. **Stop the local HTTP server:**239 ```bash240 kill $(cat /tmp/substack-post-$$/srv.pid)241 ```242243### Step 7: Save as draft2442451. Substack autosaves continuously. Wait a few seconds and verify the toolbar shows a "Saved" status button.2462. Capture the draft URL from `location.href` (it'll be `https://<publication>.substack.com/publish/post/<id>`).247248### Step 8: Confirm before publishing249250By default, **stop here**. Report to the user:251- The draft URL.252- Each PNG that was uploaded for a table (so they can re-render or replace if styling is off).253- Any remaining markdown elements that didn't convert cleanly (currently: relative-path images like `./foo.png` — those need manual upload because the source PNGs aren't reachable from Substack).254- Ask: "Draft saved. Want me to click Publish?"255256Only if the user explicitly asked to publish (in their original request or in reply), continue:2571. Click "Continue".2582. On the audience step, choose "Send to everyone now" (or whatever the user specified).2593. Click the final publish/send button.2604. Take a snapshot to verify the post is live.2615. Report the public post URL.262263## Error Handling264265- **"Sign in with password" link not found**: the page layout may have changed — take a screenshot and report.266- **Password login fails / "no password set"**: tell the user to set a password at `https://substack.com/account/login-options` and retry.267- **2FA / captcha / device-verification page**: stop, ask the user to complete the challenge in the same browser session, then retry.268- **File not found / unreadable**: stop and report the path.269- **Editor element not found**: take a screenshot and report what the page shows.270- **Rate limit on login**: Substack may block rapid retries — tell the user to wait a few minutes.271- **Paste produced double content**: don't try to surgically delete duplicates — `Ctrl/Cmd+A`, `Delete`, then paste once more. Manual deletion can cascade and lose unrelated paragraphs (Substack's `Shift+End` selects across more than one visual line in long paragraphs).272- **Table image didn't insert**: confirm the localhost server is still running (`curl -I http://127.0.0.1:8765/tableN.png`), confirm the clipboard write succeeded (look for `ok: true`), and confirm the cursor is inside the editor (not in a textarea like the title). Re-run the localhost-tab `clipboard.write` then `Ctrl/Cmd+V` in the Substack tab.273274## Important Notes275276- **Markdown fidelity is high *via the HTML clipboard route*.** Headings, bold/italic, links, ordered/unordered lists, fenced code blocks, horizontal rules, and inline `<code>` all render correctly when you paste `text/html`. **Tables** require the screenshot detour because Substack has no table primitive. **Images with relative paths** (`./foo.png`) won't work — Substack can't reach the local file. If the post needs them, either upload manually after the draft is saved, or upload them to a public host (S3, the user's blog CDN, GitHub raw) and rewrite the `src` to an absolute URL before pasting.277- **Synthetic clipboard events do not work.** Don't try `editor.dispatchEvent(new ClipboardEvent('paste', { clipboardData }))` — ProseMirror ignores it. Always use `navigator.clipboard.write` followed by a real `Ctrl/Cmd+V` keypress.278- **Drafts are private.** This skill stops at "Save draft" by default. Nothing is sent to subscribers until Publish is confirmed.279- **Magic-link-only accounts won't work.** Password login requires the user to have explicitly set a password at substack.com/account/login-options.280- **Don't hardcode credentials.** Always read from env vars or `.socials`; never paste credentials into code or chat.281- **Clean up the tmp working dir** at the end — `rm -rf /tmp/substack-post-$$` — and stop the HTTP server.