seo-blog-writer
End-to-end pipeline for shipping a single long-tail blog post: topic -> research -> draft -> scrub -> AI-SEO audit -> publish. Designed for SEO and AI-citation extractability (FAQ blocks, BreadcrumbList + FAQPage + HowTo schema, query-phrased headings).
The writing pipeline is platform-agnostic — it produces a publish-ready bundle (clean HTML, slug, meta, JSON-LD blocks, feature-image alt). The publish step is pluggable: out-of-the-box adapters for Ghost Admin API, WordPress REST, and static-site file output. Adding another CMS (Webflow, Sanity, Strapi, Contentful, Hugo, Astro) is a matter of writing a 20-line POST snippet.
The skill takes one required argument: the topic. Optional flags control the publish target and state.
/seo-blog-writer <topic>
/seo-blog-writer <topic> --target ghost # publish via Ghost adapter
/seo-blog-writer <topic> --target wordpress # publish via WordPress REST
/seo-blog-writer <topic> --target static --out posts/ # write files into a static-site repo
/seo-blog-writer <topic> --target ghost --publish # actually publish (default: draft)
/seo-blog-writer <topic> --target ghost --publish-at <ISO> # schedule for future publish
/seo-blog-writer <topic> --angle "<angle>" # narrow the angle
Default state is draft — the post lands in the platform's editor for human review before going live, unless --publish or --publish-at is passed. --publish-at accepts an ISO 8601 UTC timestamp (e.g. 2026-05-10T07:42:00Z) and is mutually exclusive with --publish.
Default --target is static — writes a self-contained HTML file + a metadata.json next to it so you can wire any platform yourself.
Before you start — preflight
The platform-agnostic checks:
# 1. Python available (rasterizer, scrubber, schema builder)
command -v python3
# 2. Working directory writable
mkdir -p tmp/blog-drafts && touch tmp/blog-drafts/.touch && rm tmp/blog-drafts/.touch
3. (Optional) ai-seo MCP — check before continuing
Check whether the current agent session has access to a tool named audit_page from the ai-seo-mcp server (@automatelab/ai-seo-mcp). That MCP provides a programmatic citation-worthiness and schema score that Step 5 uses automatically when available.
-
If the MCP is connected: nothing to do — Step 5 will call
audit_pageautomatically. -
If the MCP is not connected: ask the user:
"The ai-seo MCP (
@automatelab/ai-seo-mcp) is not connected. Step 5 can run a programmatic citation-worthiness and schema score on your draft in addition to the manual audit. To install it:npx -y @automatelab/ai-seo-mcpthen register it in your MCP config. See the ai-seo-mcp README for one-line configs for Claude Code, Cursor, and Cline. Type skip to continue with the manual-only audit."
Wait for the user's response before continuing to Step 0. Any response other than a config/install action counts as skip — proceed without the MCP.
Platform-specific credential checks live in the per-adapter sections at the end of this skill. The writing pipeline (Steps 0-7) runs without any platform credentials — credentials are only needed at Step 8.
Step 0 — Parse and classify the topic
The topic is the one thing the skill cannot invent. It must arrive as an argument.
| Shape | Example | Treatment |
|---|---|---|
| Long-tail how-to | "how to fix n8n HTTP Request 401 error" | Ideal. Format = troubleshooting (template 1). |
| Integration walk-through | "how to connect Airtable to Slack with Zapier" | Format = integration (template 2). |
| Workflow tutorial | "automate invoice processing with Make" | Format = workflow tutorial (template 3). |
| Comparison | "Zapier vs Make vs n8n" | Format = comparison (template 4). |
| Definition / explainer | "what is an AI agent" | Format = explainer (template 5). |
| Use case / outcome | "build a daily Slack digest from RSS with n8n" | Format = use-case (template 6). |
| Listicle / roundup | "12 best n8n templates for marketing teams" | Format = listicle (template 7). |
| Migration guide | "migrate from Zapier to n8n" | Format = migration (template 8). |
| Release recap | "what's new in n8n 1.80" | Format = release-recap (template 9). |
| Too vague | "AI", "automation" | Stop. Ask the user to narrow it. Suggest 2-3 candidate long-tail variants. |
If --angle was passed, append it to the topic. The classification picks the structural template used in Step 3.
Step 1 — Research
The piece must be specific. Real version numbers, real error messages, real screenshots — not generic "best practices."
1a. Identify the search intent
What does someone typing this query want? One sentence — the implicit desire behind the words.
"how to fix n8n HTTP 401"-> wants the exact change to make in the UI to stop the error"Zapier vs Make"-> wants a quick decision, then a longer breakdown"what is an AI agent"-> wants a one-paragraph explanation, then how it differs from a workflow
If you can't write one sentence describing the intent, the topic is too vague — go back to Step 0.
1b. Seed search and SERP teardown
WebSearch("<topic>")
WebSearch("<topic> <current-year>") # force a fresh lens
Extract three structured signals from the page-1 results:
- Word count distribution — eyeball the top 5 results' length. Target 1.1–1.3x the median, not the longest. If the median is 600 words, don't write 1500 — that's padding.
- People Also Ask boxes — Google surfaces 4-8 PAA questions for most queries. These are free FAQ content. Capture verbatim into the FAQ-variant list.
- Currently-winning featured snippet — if there is one, note its format (paragraph, list, table). Write the lead paragraph in that exact shape; that's how you challenge for the snippet.
Goal: write something more specific or more current than the existing top results, not a paraphrase.
1c. Deep fetch
Pick 2-4 URLs from the SERP. Prioritize:
- Vendor docs — primary sources for the tool being discussed.
- GitHub issues / changelogs — for "fix X error" topics, the actual issue thread is gold.
- Reddit / community forums — for confirming a workaround actually works in the wild.
- Existing top-ranked posts — to see the bar you're clearing.
WebFetch(url, "Return the full article body as clean prose. Include code snippets,
error messages, and screenshot references verbatim. Do NOT summarize.")
Skip SEO-farm rewrites and listicles with no specifics.
1d. Five-question gate before drafting
Before writing, you must be able to answer all five.
- What is the exact query intent? (one sentence from 1a)
- What is the direct answer? (one to two sentences — the lead paragraph in compressed form)
- What's the canonical primary source? (vendor doc, GitHub issue, official changelog — at least one URL)
- What's the gotcha most existing posts miss? (the specific detail that makes this post worth writing). Hard rule: if the honest answer is "nothing, I'm summarizing the docs," abort and tell the user. A doc paraphrase will rank below the actual docs.
- What 3-6 follow-on questions belong in the FAQ? (long-tail variations of the main query, ideally lifted from the PAA boxes captured in 1b)
If any answer is ?, keep researching or ask the user for a specific source.
1e. Save research artifacts
mkdir -p tmp/blog-drafts
# <slug> = kebab-case of the topic, e.g. n8n-http-401-fix
Files (gitignored):
tmp/blog-drafts/<slug>.research.md— 5-question answers, source list, key quotestmp/blog-drafts/<slug>.interlinks.json— written in Step 1f (outbound interlink targets)tmp/blog-drafts/<slug>.draft.html— written in Step 3tmp/blog-drafts/<slug>.schema.html— written in Step 7b (JSON-LD<script>blocks)tmp/blog-drafts/<slug>.metadata.json— written in Step 7f (title, slug, tags, meta, etc.)tmp/blog-drafts/<slug>.refresh.json— written in Step 7h (versions, prices, years cited; for future refresh runs)
1f. Outbound interlinks (recommended; required for >800-word posts)
Pick 2-3 prior posts on the same site whose topic genuinely overlaps with this one. Bake the links into the draft in Step 3 on topical noun phrases (not "see this post"). Internal links don't carry nofollow; outbound links to other domains do (see Step 3 link policy).
Where the candidate list comes from depends on the platform:
- Ghost —
GET /ghost/api/admin/posts/?limit=all&filter=status:published&fields=id,slug,title,published_at,custom_excerpt&order=published_at%20desc(sameGHOST_ADMIN_KEYStep 8 uses). - WordPress —
GET /wp-json/wp/v2/posts?per_page=100&_fields=id,slug,title,date,excerpt&orderby=date&order=desc(sameWP_APP_PASSWORDStep 8 uses). - Static-site — read the SSG's content directory directly (
ls content/posts/*.md) or maintain a hand-curatedposts-inventory.jsonin the repo.
Save the chosen targets so Step 3 can splice them in and Step 7g can verify they survived the audit:
cat > tmp/blog-drafts/<slug>.interlinks.json <<'EOF'
{
"outbound": [
{"slug": "<prior-slug-1>", "url": "https://<your-host>/<prior-slug-1>/", "anchor_phrase": "<noun phrase>"},
{"slug": "<prior-slug-2>", "url": "https://<your-host>/<prior-slug-2>/", "anchor_phrase": "<noun phrase>"}
]
}
EOF
Step 7g verifies that every outbound[].url appears at least once as an href in the final draft. If you decided mid-draft to drop a link, edit the file before re-running 7g. Posts under 800 words can skip this step; long posts ship with outbound links or they look orphaned to both the reader and the site graph.
Note on inbound links. Editing prior posts after publish to add a forward link back to the new one (inbound splicing) is a separate concern that depends on having write access to historical posts and a state file to keep the operation idempotent. This skill does not handle it — too platform-specific to generalize. If you want it, run it as a cron against your platform's API after publish.
Step 2 — Pick the format and length band
Each query type maps to a structural template:
| Format | Length band |
|---|---|
| how-to-fix (troubleshooting) | 600-1200 |
| how-to-connect (integration) | 1000-1500 |
| how-to-automate (workflow) | 1000-1500 |
| x-vs-y (comparison) | 1200-1500 |
| what-is (explainer) | 600-1200 |
| use-case (outcome) | 1000-1500 |
| listicle (roundup) | 1500-2500 |
| migration | 1200-1800 |
| release-recap | 800-1400 |
Hard length range: 600-1500 words for most formats. Word count = prose inside <p> tags + heading text. Excludes code blocks, table cells, figcaptions.
Use the SERP word-count signal from Step 1b to pick a target inside the band (1.1–1.3x the SERP median). Under the floor means the answer is genuinely too thin — add an FAQ expansion, a "common errors" section, or a "how to verify" section. Over the ceiling means the post is sprawling — cut the weakest section. Never pad to hit a floor. Google rewards directness; AI Overviews preferentially extract from concise answers.
Step 3 — Draft the post
Write directly in HTML. Allowed tags:
<p>, <h2>, <h3>, <a>, <strong>, <em>, <code>, <pre>, <blockquote>, <ul>, <ol>, <li>, <table>, <thead>, <tbody>, <tr>, <th>, <td>, <figure>, <figcaption>, <img>.
No inline styles. No <div>, no <span>, no <br>. No H1 (most platforms emit the post title as H1; emitting your own creates a duplicate).
Link policy — internal vs. outbound, follow vs. nofollow
| Destination | rel attribute |
|---|---|
| Your own blog (other posts on the same host) | none — internal, follow |
| Anything else (vendor docs, GitHub, news, social, all third-party) | rel="nofollow noopener" |
Do not use target="_blank" — most blog themes handle outbound link UX themselves. Set CANONICAL_HOST=blog.example.com in the shell before running the audit in Step 5 so the validator knows which links are internal.
Voice checks while drafting
- Open with a TL;DR block. First child of the body is
<p><strong>TL;DR:</strong> ...</p>— a single sentence, 8-40 words, that answers the query directly with specific nouns (tool name, version, error code, command). LLM citation hook. Asserted in Step 7g. - Lead paragraph follows the TL;DR with one or two sentences of context (when this hits, who it bites, why other guides miss the cause). It is not a re-statement of the answer.
- H2 as a question or operational label. Every
<h2>either ends with?(e.g.## How do you fix the "ECONNREFUSED" error in n8n?) or is one of the allowlist:Install,Prerequisites,Links,TL;DR,FAQ,Frequently asked questions,Summary,References,Further reading,Sources,Bottom line.<h3>follows the same convention. Question-shaped H2s are how Google AI Overviews and Perplexity slice the page into citable chunks. Asserted in Step 7g. - Specific over general. Real version numbers, real error messages, real prices. No "modern", "powerful", "robust", "seamless."
- Impersonal voice. "Here's the fix." Not "we found that" and not "I tried this."
- Forensic linking. Every external claim links on the noun phrase that names the source. Every external link has
rel="nofollow noopener". - Bullet discipline. No
<ul>or<ol>under 3 items — convert to prose. No list over 9 items without a sub-grouping (split into 2 lists under separate H3s, or fold into a<table>). Every<li>carries a data point, recommendation, or argument; each ends with a period; parallel grammar across items. Asserted in Step 7g. - Structured-spec labels for diagnostic posts. Troubleshooting roundups, "N reasons X is broken", and cause/effect listicles repeat a labeled triple inside every item — the default is
**Symptom:**/**Diagnostic:**/**Fix:**(one paragraph each). The bold-keyword-colon form is allowed here and only here. For migration posts use**Before:**/**After:**/**Migration step:**; for comparison posts use**When to pick:**/**Avoid if:**/**Cost:**. This is what gets AI assistants to extract per-item structured citations instead of mashing the whole list into one quote. - Recap checklist before the FAQ for enumerative posts. Posts with three or more enumerated items close with an
<ol>of one-sentence imperative steps under a question-shaped H2 (e.g.<h2>How do you test all seven blockers in 20 minutes?</h2>). One step per body item, no sub-bullets. Skip for posts under 800 words or fewer than three items. The recap is what gets quoted as the AI-answer "summary" — without it the model has to invent one. - Currency where it matters. Any version number, year, or price in a load-bearing claim either is current (cross-check against vendor docs in Step 5) or carries
as of <YYYY-MM>next to it so a reader knows the time-context. Step 7g flags any year > 1 year stale without an explicitas ofqualifier. - End with a
<h2>FAQ</h2>block — 3-6 H3 questions, each with a 1-3 sentence answer. - Self-check: Does the TL;DR stand alone as a quotable answer? Does the lead paragraph add context the TL;DR doesn't have? If either fails, rewrite.
Save to tmp/blog-drafts/<slug>.draft.html.
Step 4 — Scrub LLM tells
Run before the AI-SEO audit. The audit may add vocabulary the scrub would then need to remove; do the order this way.
4a. Character scrub (automatic)
Replace common LLM-tell characters with ASCII equivalents:
python3 -c "
import sys, pathlib
p = pathlib.Path(sys.argv[1])
t = p.read_text(encoding='utf-8')
# em-dash/en-dash -> hyphen
t = t.replace('—', '-').replace('–', '-')
# smart quotes -> straight quotes
t = t.replace('“', '\"').replace('”', '\"')
t = t.replace('‘', \"'\").replace('’', \"'\")
# ellipsis -> three dots
t = t.replace('…', '...')
# zero-width / non-breaking space -> regular space or empty
t = t.replace('', '').replace(' ', ' ')
p.write_text(t, encoding='utf-8')
print('scrubbed', sys.argv[1])
" tmp/blog-drafts/<slug>.draft.html
4b. Prose-level tells (manual)
Search the draft for these banned phrases and rewrite:
- "delve into", "delving"
- "in today's fast-paced world", "in the ever-evolving"
- "robust", "seamless", "powerful", "cutting-edge"
- "harness the power of"
- "it's worth noting that", "it's important to note"
- "navigate the landscape", "navigating the complexities"
- "unlock the potential of", "unleash"
- "game-changer", "revolutionize"
- "leverage" (as a verb)
Rewrite every hit — do not just delete; the surrounding sentence is usually also lazy.
Step 5 — AI-SEO audit
Programmatic pass (if ai-seo-mcp is connected)
If the ai-seo-mcp server is connected, call audit_page on the draft before running the manual passes:
audit_page(url_or_path="tmp/blog-drafts/<slug>.draft.html")
Feed the score and any flagged issues into the manual passes below as additional signal. The MCP output is advisory — the six manual passes are still required gates.
Manual passes
Run the audit against the draft, checking each pass:
- Structure pass — does the lead answer the query in the first paragraph; do H2s match query phrasing; is each section self-contained.
- Authority pass — at least one cited primary source (vendor doc / GitHub issue / changelog) on a relevant noun phrase.
- Freshness pass — current year referenced where it makes sense; version numbers are current. Currency check, mandatory: any version number cited must still be the current (or one of the still-supported) versions per vendor docs. A 6-month-old "introduced in CrewAI 0.114" may now read as historical context, not present-tense scope. If the version has rolled forward, either update the framing or add
as of <YYYY-MM>next to the claim so the reader knows the time-context. Vendors ship fast; stale qualifiers tank citation quality. - Schema readiness — most platforms emit Article + Person + Organization schema automatically. Step 7b adds FAQPage + BreadcrumbList (always) and HowTo (procedural posts only). Confirm the FAQ block has H3 question + paragraph answer pairs the 7b extractor can parse.
- Long-tail coverage — does the FAQ block capture 3-6 long-tail variants of the main query.
- Platform-fact pass — any claim about a specific shell, OS, language runtime, or tool is a verifiable fact, not a vibe. Verify the load-bearing ones against vendor docs before publish.
Apply recommendations in place in the draft, then re-run Step 4a (the audit may have re-introduced smart quotes).
Non-negotiable invariants
- Body is within the format's length band (Step 2). Count via the snippet below.
- TL;DR is the first
<p>of the body, opens with<strong>TL;DR:</strong>, 8-40 words, single sentence. - Lead paragraph (second
<p>) answers the query in 1-2 sentences. - At least one primary-source link with
rel="nofollow noopener". - FAQ block at the end with 3-6 H3/p pairs.
- Every external
<a>carriesrel="nofollow noopener". - Zero U+2014, U+201C, U+201D, U+2018, U+2019, U+2026, U+00A0, U+200B.
# Word count (excludes code blocks, table cells, figcaptions)
python3 -c "
import sys, re, pathlib
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
no_code = re.sub(r'<pre\b[^>]*>.*?</pre>', ' ', html, flags=re.S|re.I)
no_table = re.sub(r'<table\b[^>]*>.*?</table>', ' ', no_code, flags=re.S|re.I)
no_fig = re.sub(r'<figure\b[^>]*>.*?</figure>', ' ', no_table, flags=re.S|re.I)
text = re.sub(r'<[^>]+>', ' ', no_fig)
words = re.findall(r\"[A-Za-z0-9][A-Za-z0-9'-]*\", text)
print(f'{len(words)} words')
" tmp/blog-drafts/<slug>.draft.html
# nofollow coverage on external links — expected: 0 violations.
# Set CANONICAL_HOST to your blog's hostname (e.g. blog.example.com).
python3 -c "
import re, sys, pathlib, os
from urllib.parse import urlparse
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
host = os.environ.get('CANONICAL_HOST', '')
internal = {host, f'www.{host}' if host else ''}
internal = {h for h in internal if h}
violations = []
for m in re.finditer(r'<a\b([^>]*)>', html, flags=re.I):
attrs = m.group(1)
href = re.search(r'href=\"([^\"]+)\"', attrs, flags=re.I)
if not href: continue
h = urlparse(href.group(1)).hostname or ''
if h and h not in internal:
rel = re.search(r'rel=\"([^\"]+)\"', attrs, flags=re.I)
rel_val = (rel.group(1) if rel else '').lower()
if 'nofollow' not in rel_val:
violations.append(href.group(1))
for v in violations: print('MISSING nofollow:', v)
print(f'{len(violations)} violation(s)')
" tmp/blog-drafts/<slug>.draft.html
Step 6 — Illustrate the post (optional)
Figures are not required for short posts, but mandatory for posts >=800 words. The rule: figures >= max(1, words // 500) whenever body word count >=800. An 800-word post -> 1-2 figures. A 1200-word post -> 2-3. A 1500-word post -> 3. Step 7g asserts this. Past failure mode this rule is fixing: long troubleshooting posts (1000+ words) shipped with zero figures because the agent declared the topic "too definitional" — the assert refuses those bundles.
For figure generation (SVG flow diagrams, comparison charts, taxonomy diagrams, OG feature cards) see the companion blog-figure-svg skill — it generates accessible SVGs with consistent styling and rasterizes them for upload. The skill is CMS-agnostic; it produces PNG files that any adapter in Step 8 can upload.
For screenshots, capture from the live tool (Playwright, real session, etc.), crop to the relevant region, redact tokens or personal data. Save as tmp/blog-drafts/<slug>-<N>-<short-name>.png.
Splice figure tags into the draft
<figure>
<img src="<image-url-or-path>" alt="<full description with all numbers and labels>" loading="lazy">
<figcaption>One sentence restating the takeaway in plain English (15-30 words).</figcaption>
</figure>
Caption rules:
- Required on every figure. Step 7g asserts this.
- 15-30 words, restating the takeaway (not "Figure showing X" — say what the reader should conclude).
- Allowed tags inside
<figcaption>:<a>(withrel="nofollow noopener"for external),<em>.
The <img src> value depends on the publish target:
- Ghost / WordPress: upload first (per-adapter snippet in Step 8), then splice the returned CDN URL.
- Static-site: copy the PNG into the site's image directory and use a relative path.
Step 7 — Build the publish bundle
The bundle is three files that every adapter consumes:
| File | Contents |
|---|---|
| <slug>.draft.html | Body HTML (already produced in Step 3, scrubbed and audited). |
| <slug>.schema.html | JSON-LD <script> blocks (FAQPage + BreadcrumbList + optional HowTo). |
| <slug>.metadata.json | Title, slug, tags, author, meta title/description, excerpt, feature image, status, publish-at. |
7a. Headline and slug rules
Headline (becomes the SEO title unless meta_title overrides):
- Under 70 chars.
- Match the search query closely.
- Lead with the verb / noun the searcher typed.
Slug (URL fragment):
- <=60 chars.
- Strip stop words — drop
the,a,an,for,with,in,to,of,on,and,or,is,are. - No version numbers —
n8n-1-45-2-fixgoes stale;n8n-http-401-fixdoes not. - Match the primary keyword, not the full headline.
import re
STOP = {'the','a','an','for','with','in','to','of','on','and','or','is','are'}
slug = "-".join(t for t in re.findall(r'[a-z0-9]+', topic.lower()) if t not in STOP)
slug = slug[:60].rstrip('-')
7b. Build JSON-LD schema (FAQPage + BreadcrumbList + HowTo)
Most platforms emit Article/BlogPosting/Person/Organization schema by default. This skill adds three more for AI-citation extractability:
- FAQPage — mandatory. Every post has a FAQ block (Step 3 rule).
- BreadcrumbList — mandatory.
Home > <Primary Tag> > <Post Title>. - HowTo — only for procedural formats with >=3 step-shaped H2s.
Critical gotcha for rich-text editors: several CMSes (Ghost's Lexical, WordPress's block editor under some configurations) convert the source HTML into a structured format on save and silently drop <script> nodes — so JSON-LD inlined in the draft body disappears in the live page even though it was present in the POST payload.
The blocks must go in a platform-specific "head injection" slot:
| Platform | Where the schema goes |
|---|---|
| Ghost | codeinjection_head field on the post payload |
| WordPress | <head> via a theme hook, or the Yoast / Rank Math "schema" panel |
| Static-site | written directly into the rendered HTML's <head> by your build step |
Never append <script type="application/ld+json"> to the body HTML. Build it once via this step into <slug>.schema.html; the platform adapter in Step 8 reads that file and writes it into the correct field.
# Args: slug, headline, format, primary-tag-name, canonical-base-url
python3 scripts/seo-blog-writer/build-schema.py "<slug>" "<headline>" "<format>" "<primary-tag>" "<canonical-base-url>"
7c. Feature image (recommended)
A feature image is shown at the top of the post and as the OG image in social shares. Strongly recommended for any post you intend to promote.
Options:
- Upload a custom image — per-adapter upload snippets are in Step 8.
- Generate a templated title card — see the companion
blog-figure-svgskill (featurevariant) for a 1600x840 OG card with a clean headline + brand mark. - Skip it — the post will render without a hero image; social previews fall back to the site default.
Whatever path you pick, capture the URL (or filesystem path for static targets) plus a one-line alt-text in metadata.json. Cap alt text at 191 chars — Ghost silently truncates at varchar(191), and the limit is a reasonable upper bound for any platform.
7d. Author byline
Every post needs an author. The shape varies by platform; capture it generically in metadata:
"author": {"slug": "<author-slug>", "name": "<display name>"}
The adapter in Step 8 translates this to the platform's API shape:
- Ghost —
authors: [{"slug": "<slug>"}]. Slug must match an existing user; otherwise Ghost silently substitutes the integration owner. - WordPress —
author: <user-id>(numeric). Resolve slug -> id once and cache. - Static-site — written into the front-matter
author:field of the generated file.
7e. Tags
Use a flat list of tag name strings:
"tags": ["How To", "n8n"]
Pick 1-3 tags per post. The first tag is the primary tag — it becomes the breadcrumb segment in 7b and is used by most themes for category labelling.
Maintain a small canonical tag list in your project (don't let the AI invent new tags every post — duplicates dilute SEO). Common patterns: format tags (How To, Tutorial, Comparison, What Is) + topic tags (your tool/category names).
7f. Build the metadata bundle
Write the per-post fields into tmp/blog-drafts/<slug>.params.json, then run the
builder. It validates required fields and maps the status flags to every adapter.
params.json shape:
{
"title": "<headline>",
"tags": ["How To", "n8n"],
"author": {"slug": "<author-slug>", "name": "<author display name>"},
"meta_title": "<SEO title under 60 chars>",
"meta_description": "<SEO description, 140-160 chars>",
"custom_excerpt": "<dek shown on index page>",
"feature_image": "",
"feature_image_alt": "",
"feature_image_caption": "",
"publish": false,
"publish_at": null
}
First tag is the primary tag (passed to 7b for the breadcrumb). Set publish: true
for --publish; publish_at (ISO-UTC) for --publish-at (mutually exclusive).
python3 scripts/seo-blog-writer/build-metadata.py "<slug>"
7g. Pre-publish bundle validation
Before invoking the platform adapter, all of these must hold:
python3 scripts/seo-blog-writer/validate-bundle.py "<slug>"
If any assert fires, fix and re-build before Step 8.
7h. Refresh metadata snapshot
Save a small JSON snapshot of the post's facts so a future refresh pass can identify staleness without re-reading the prose. Cheap to write now; expensive to backfill at 500 posts.
python3 scripts/seo-blog-writer/refresh-meta.py "<slug>" "<format>"
When a topic refresh comes due (typically every 6-12 months for high-traffic posts), the refresh skill (future / your-own) diffs the snapshot's versions_cited against current vendor docs. Versions that have rolled forward by a major release are flagged for rewrite; everything else is left alone.
7i. Glossary auto-link (optional)
If you maintain a glossary of technical terms with definition pages on your site, pipe the draft HTML through scripts/inject-glossary-links.py to turn the first mention of each known term into an internal link to its definition page. Each link also carries a data-definition attribute that the bundled references/decorate.js snippet renders as a hover tooltip on the published page.
Skip this step if you don't have a glossary.json file yet — there's no default. See references/glossary-schema.md for the file shape and a starter example.
python3 scripts/inject-glossary-links.py \
tmp/blog-drafts/<slug>.draft.html \
--glossary path/to/glossary.json \
--base-url /glossary/ \
--max-links 6 \
> tmp/blog-drafts/<slug>.draft.linked.html
mv tmp/blog-drafts/<slug>.draft.linked.html tmp/blog-drafts/<slug>.draft.html
The injector:
- Links first occurrence only per term per post (Wikipedia rule).
- Caps at
--max-links(default 6), priority-sorted from the glossary. - Skips headings, code/pre, tables, blockquotes, asides, existing links, and the TL;DR paragraph.
- Rejects matches embedded in identifier-like compounds (
user-agentwon't matchagent,@scope/ai-seo-mcpwon't matchmcp). - Writes a
data-definitionattribute on each link for the tooltip.
Run order: after Step 7g validates the draft so the validator's structural asserts run on clean HTML; before Step 8 publishes so the linked HTML is what ships. Glossary links count as internal navigation, not outbound — the Step 7g outbound-survival assert ignores them.
To enable the hover tooltip on the live site, copy skills/seo-blog-writer/references/decorate.js into your theme bundle (or paste it inline in a <script> tag in your site <head>) once. It's self-contained, ~1 KB, no dependencies, and skips itself on /glossary/* pages.
Step 8 — Publish via the platform adapter
Pick one adapter per run. Each adapter reads the same bundle (<slug>.draft.html, <slug>.schema.html, <slug>.metadata.json) and writes the post to its target platform.
Adapter A — Ghost (Admin API)
The Ghost adapter uses the Admin API over HTTPS. No Docker, no SSH — just authenticated POST to /ghost/api/admin/posts/.
Credentials:
| Env var | Source | Shape |
|---|---|---|
| GHOST_URL | Your Ghost site URL | https://blog.example.com (no trailing slash) |
| GHOST_ADMIN_KEY | Ghost admin -> Settings -> Integrations -> (your integration) -> Admin API Key | <24-hex>:<64-hex> combined |
Preflight:
curl -sS "$GHOST_URL/ghost/api/admin/site/" | head -c 80
[ -n "$GHOST_URL" ] && [ -n "$GHOST_ADMIN_KEY" ] && echo "keys present" || echo "MISSING"
Image upload (call once per figure, then splice the returned URL into the draft):
python3 scripts/seo-blog-writer/ghost-upload-image.py "<image-path>"
Publish the post:
python3 scripts/seo-blog-writer/publish-ghost.py "<slug>"
?source=html tells Ghost to convert the html field into Lexical. Without it, Ghost treats the field as Lexical JSON and the POST fails with a 422.
Python deps: pip install requests pyjwt. PyJWT 2.x required.
Adapter B — WordPress (REST API)
Uses the WordPress REST API with Application Password auth (Users -> Profile -> Application Passwords). Works on any WP site with REST exposed at /wp-json/wp/v2/.
Credentials:
| Env var | Source | Shape |
|---|---|---|
| WP_URL | Your WordPress site URL | https://blog.example.com (no trailing slash) |
| WP_USER | The WP username the app password belongs to | admin |
| WP_APP_PASSWORD | Profile -> Application Passwords -> new -> "seo-blog-writer" | xxxx xxxx xxxx xxxx xxxx xxxx |
Preflight:
curl -sS "$WP_URL/wp-json/wp/v2/" | head -c 120
[ -n "$WP_URL" ] && [ -n "$WP_USER" ] && [ -n "$WP_APP_PASSWORD" ] && echo "keys present" || echo "MISSING"
Image upload (returns the media id and URL):
python3 scripts/seo-blog-writer/wp-upload-image.py "<image-path>"
Publish the post:
python3 scripts/seo-blog-writer/publish-wordpress.py "<slug>"
Notes:
featured_mediain the post payload is a media id, not a URL. Upload the feature image first, capture the id, then setpost["featured_media"] = <id>.- WordPress accepts
<script>incontentonly if the user has theunfiltered_htmlcapability (admins do by default; editors may not). If your user lacks it, install a small theme snippet that reads the schema from a post meta key intowp_head.
Adapter C — Static-site (file output)
For Hugo / Astro / Eleventy / Jekyll / Next-MDX style setups where posts live as files in a git repo. The adapter writes the bundle into the target directory; your usual build + deploy takes it from there.
No credentials. Just a target path.
python3 scripts/seo-blog-writer/publish-static.py "<slug>" "<out-dir>"
Your SSG's layout template needs one line to include the schema in <head> — e.g. for Hugo:
{{ if (fileExists (printf "content/posts/%s.schema.html" .File.BaseFileName)) }}
{{ readFile (printf "content/posts/%s.schema.html" .File.BaseFileName) | safeHTML }}
{{ end }}
For Astro / Eleventy / Next, do the equivalent (read file at build time, inject into the layout head).
Adapter D — bring-your-own
The bundle is a stable contract. Any platform with an "upload an image" and a "create a post" endpoint can be adapted in ~50 lines. The contract:
<slug>.draft.html— body HTML, post-scrub, post-audit.<slug>.schema.html— JSON-LD<script>blocks to inject in<head>.<slug>.metadata.json— title, slug, tags (string list), author (slug + name), meta title/desc, excerpt, feature image (URL or local path), status (draft/published/scheduled), published_at (ISO).
Adapter examples shipped above (Ghost, WordPress, static) cover ~90% of small-publisher use cases. Webflow CMS, Sanity, Strapi, and Contentful each take a similar shape: POST to the platform's content endpoint with their auth header, body field, and metadata fields.
Step 8b. Report back to the user
Whatever adapter ran, the final report includes:
- Draft URL or live URL (
<base-url>/<slug>/if published; admin edit URL if draft). - Platform admin / repo edit URL.
- Word count, tag list, author slug.
- Confirmation: scrub passed, AI-SEO audit applied, FAQ block present, JSON-LD injected.
- Figure URLs and captions.
Step 9 — Verify live post (only if --publish)
# Post is reachable
curl -sSI "<base-url>/<slug>/" | head -5
# Post in RSS
curl -sS "<base-url>/rss/" | grep -o "<title>[^<]*</title>" | head -5
# Post in sitemap (path varies by platform — Ghost: /sitemap-posts.xml; WP: /sitemap.xml; SSG: as configured)
curl -sS "<base-url>/sitemap-posts.xml" | grep "<slug>"
# OG + full schema set rendered
curl -sS "<base-url>/<slug>/" | grep -o 'property="og:[^"]*"' | sort -u
curl -sS "<base-url>/<slug>/" | grep -oE '"@type":\s*"[^"]+"' | sort -u
Expected: HTTP/2 200, slug in RSS and sitemap, og:title/og:description present. The "@type" set must include Article (or BlogPosting), FAQPage, and BreadcrumbList; procedural how-to posts must also include HowTo. Missing FAQPage/BreadcrumbList means the schema slot wasn't wired correctly — check the platform-specific head-injection field.
What this skill does NOT do
- Does not commit to git. Adapters write to CMS APIs or to your static-site directory; the latter you commit yourself.
- Does not schedule posts by default. Pass
--publish-at <ISO-UTC>to schedule. Without it the post lands as draft (default) or live (--publish). - Does not handle member-only posts, newsletters, or email sends. Each platform's newsletter flow is manual via its admin UI.
- Does not generate figures. Use the companion
blog-figure-svgskill for SVG charts, taxonomies, and flow diagrams. - Does not research topics from scratch. Use the companion
blog-topic-researchskill to validate a topic has real demand signals before drafting.
Failure modes
| Symptom | Adapter | Cause | Fix |
|---|---|---|---|
| 401 Unauthorized | Ghost / WordPress | Key expired / wrong key / wrong app-password | Regenerate the integration / app password |
| Ghost 422 Validation failed: Value in [posts.html] cannot be blank | Ghost | Missing ?source=html | Add the query param |
| Ghost 422 with feature_image_alt in message | Ghost | Alt text >191 chars | Trim to <=191; Step 7g asserts this |
| 404 on slug after publish | any | Post saved as draft (default) | Drafts only reachable via admin editor URL |
| Body shows as one HTML blob | Ghost | Ghost fell back to plain-text mode | Re-post with ?source=html |
| Smart quotes reappear in rendered post | Ghost | Ghost typographer auto-conversion | Settings -> Publication: turn off "Use typographer's quotes" |
| Wrong slug | any | Platform auto-slugged from title | PUT/PATCH the post with the corrected slug |
| Ghost 409 Conflict on PUT | Ghost | Stale updated_at | Re-GET to refresh, retry |
| Author silently substituted | Ghost / WordPress | Author slug doesn't exist / user lacks publish_posts | Create the user; PUT correction with correct slug or user id |
| Live page missing FAQPage / HowTo @type (Step 9) | Ghost | JSON-LD was inlined in the body and stripped by Lexical conversion | PUT with codeinjection_head set to <slug>.schema.html; echo current updated_at to avoid 409 |
| WordPress strips <script type="application/ld+json"> from body | WordPress | User lacks unfiltered_html | Move schema injection to a theme hook reading a post meta key |
Companion skills
blog-topic-research— validate a long-tail topic has real demand signals (PAA, Reddit threads, GitHub issues) before drafting. Run this before this skill.blog-figure-svg— generate accessible SVG figures (flow diagrams, comparison charts, taxonomy diagrams) with consistent styling. Run this during Step 6 if the post needs illustrations.
Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish.
Maintenance scripts
The per-post scrub in Step 4a covers the common LLM-tell characters and the per-post audit in Step 7g enforces the structural rules. For corpus-wide drift — characters or banlist phrases that crept back in across many posts — there's a separate audit script in the repo:
# Sweep your published-content directory for non-ASCII chars + prose banlist
python3 scripts/audit-corpus.py path/to/your/content/
# Examples (per platform):
python3 scripts/audit-corpus.py tmp/blog-drafts/ # current drafts
python3 scripts/audit-corpus.py content/posts/ # Hugo / Astro / 11ty
python3 scripts/audit-corpus.py site/source/_posts/ # Jekyll
# Add domain-specific terms you want flagged (comma-separated):
python3 scripts/audit-corpus.py content/posts/ --extra "synergy,best-in-class"
# CI mode: exit 1 on any hit, pipe to your notifier or fail the build
python3 scripts/audit-corpus.py content/posts/ >/dev/null || echo "drift detected"
Default scan covers *.html and *.md. The script exits 0 clean / 1 on hits / 2 on bad invocation, so it composes with CI. Run it weekly (or as a pre-deploy step) — much cheaper than re-reading every post by hand.
Don't point it at the publishing-skills repo itself or at the seo-blog-writer SKILL.md: both contain the banlist literals as data and will self-flag. Target your content directory, not your tooling directory.