seo-blog-writer
"Turn a single long-tail query into a publish-ready blog post that ranks in search and gets quoted by AI assistants. Runs the full pipeline: classify the topic, research it against real sources, draft clean HTML, scrub LLM-tell vocabulary and typography, audit for AI-SEO (TL;DR block, query-phrased H2s, FAQ section, FAQPage + BreadcrumbList + HowTo JSON-LD), then publish through a platform adapter (Ghost Admin API, WordPress REST, or static-site file output). Platform-agnostic core; swap the publish step without rewriting the writing pipeline. Built for indie hackers, founders, and content marketers who want AI to draft posts that are actually citable - not paraphrased docs, not hallucinated benchmarks. Trigger when the user says: 'write a blog post on X', 'draft an article about X', 'publish a post on X to Ghost / WordPress / the static site', or any request to ship editorial content for a long-tail query."
Allowed Tools
Provided by Plugin
publishing-skills
Four composable skills that turn an AI agent into a platform-agnostic long-tail SEO publishing pipeline — topic research, drafting, SVG figures, and an editorial calendar. Ships Ghost, WordPress, and static-site adapters.
Installation
This skill is included in the publishing-skills plugin:
/plugin install publishing-skills@claude-code-plugins-plus
Click to copy
Instructions
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-mcp
> ```
> then 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/— 5-question answers, source list, key quotes.research.md tmp/blog-drafts/— written in Step 1f (outbound interlink targets).interlinks.json tmp/blog-drafts/— written in Step 3.draft.html tmp/blog-drafts/— written in Step 7b (JSON-LD.schema.html blocks)tmp/blog-drafts/— written in Step 7f (title, slug, tags, meta, etc.).metadata.json tmp/blog-drafts/— written in Step 7h (versions, prices, years cited; for future refresh runs).refresh.json
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,publishedat,customexcerpt&order=publishedat%20desc(sameGHOSTADMIN_KEYStep 8 uses). - WordPress —
GET /wp-json/wp/v2/posts?perpage=100&fields=id,slug,title,date,excerpt&orderby=date&order=desc(sameWPAPPPASSWORDStep 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 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:
No inline styles. No Do not use TL;DR: ... Save to Run before the AI-SEO audit. The audit may add vocabulary the scrub would then need to remove; do the order this way. Replace common LLM-tell characters with ASCII equivalents: Search the draft for these banned phrases and rewrite: Rewrite every hit — do not just delete; the surrounding sentence is usually also lazy. If the ai-seo-mcp server is connected, call 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. Run the audit against the draft, checking each pass: Apply recommendations in place in the draft, then re-run Step 4a (the audit may have re-introduced smart quotes). Figures are not required for short posts, but mandatory for posts >=800 words. The rule: For figure generation (SVG flow diagrams, comparison charts, taxonomy diagrams, OG feature cards) see the companion For screenshots, capture from the live tool (Playwright, real session, etc.), crop to the relevant region, redact tokens or personal data. Save as Caption rules: The The bundle is three files that every adapter consumes: Headline (becomes the SEO title unless Slug (URL fragment): Most platforms emit Article/BlogPosting/Person/Organization schema by default. This skill adds three more for AI-citation extractability: 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 The blocks must go in a platform-specific "head injection" slot: Never append 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: Whatever path you pick, capture the URL (or filesystem path for static targets) plus a one-line alt-text in Every post needs an author. The shape varies by platform; capture it generically in metadata: The adapter in Step 8 translates this to the platform's API shape: Use a flat list of tag name strings: 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 ( Write the per-post fields into builder. It validates required fields and maps the status flags to every adapter. First tag is the primary tag (passed to 7b for the breadcrumb). Set for Before invoking the platform adapter, all of these must hold: If any assert fires, fix and re-build before Step 8. 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. 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 If you maintain a glossary of technical terms with definition pages on your site, pipe the draft HTML through Skip this step if you don't have a The injector: 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 Pick one adapter per run. Each adapter reads the same bundle ( The Ghost adapter uses the Admin API over HTTPS. No Docker, no SSH — just authenticated POST to Credentials: Preflight: Image upload (call once per figure, then splice the returned URL into the draft): Publish the post: Python deps: Uses the WordPress REST API with Application Password auth (Users -> Profile -> Application Passwords). Works on any WP site with REST exposed at Credentials: Preflight: Image upload (returns the media id and URL): Publish the post: Notes: 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. Your SSG's layout template needs one line to include the schema in For Astro / Eleventy / Next, do the equivalent (read file at build time, inject into the layout head). 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: 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. Whatever adapter ran, the final report includes: Expected: Together, the three form a complete long-tail SEO publishing pipeline: research the topic, write the post, illustrate it, publish. 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: Default scan covers 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., , , , , , , , , , , , ,
, , , , , , , .
, no . 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"target="blank" — most blog themes handle outbound link UX themselves. Set CANONICALHOST=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
— 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. 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. follows the same convention. Question-shaped H2s are how Google AI Overviews and Perplexity slice the page into citable chunks. Asserted in Step 7g.rel="nofollow noopener". or 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 ). Every
carries a data point, recommendation, or argument; each ends with a period; parallel grammar across items. Asserted in Step 7g.
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. of one-sentence imperative steps under a question-shaped H2 (e.g. ). 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.How do you test all seven blockers in 20 minutes?
as of next to it so a reader knows the time-context. Step 7g flags any year > 1 year stale without an explicit as of qualifier. block — 3-6 H3 questions, each with a 1-3 sentence answer.FAQ
tmp/blog-drafts/.
Step 4 — Scrub LLM tells
4a. Character scrub (automatic)
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)
Step 5 — AI-SEO audit
Programmatic pass (if ai-seo-mcp is connected)
audit_page on the draft before running the manual passes:
audit_page(url_or_path="tmp/blog-drafts/<slug>.draft.html")
Manual passes
as of next to the claim so the reader knows the time-context. Vendors ship fast; stale qualifiers tank citation quality.Non-negotiable invariants
of the body, opens with TL;DR:, 8-40 words, single sentence.) answers the query in 1-2 sentences.rel="nofollow noopener". carries rel="nofollow noopener".
# 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 >= 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.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.tmp/blog-drafts/.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>
: (with rel="nofollow noopener" for external), . value depends on the publish target:
Step 7 — Build the publish bundle
File
Contents
Body HTML (already produced in Step 3, scrubbed and audited).
JSON-LD
blocks (FAQPage + BreadcrumbList + optional HowTo).
Title, slug, tags, author, meta title/description, excerpt, feature image, status, publish-at.
7a. Headline and slug rules
meta_title overrides):
the, a, an, for, with, in, to, of, on, and, or, is, are.n8n-1-45-2-fix goes stale; n8n-http-401-fix does not.
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)
Home > . nodes — so JSON-LD inlined in the draft body disappears in the live page even though it was present in the POST payload.
Platform
Where the schema goes
Ghost
codeinjection_head field on the post payload
WordPress
via a theme hook, or the Yoast / Rank Math "schema" panel
Static-site
written directly into the rendered HTML's
by your build step to the body HTML. Build it once via this step into ; 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)
blog-figure-svg skill (feature variant) for a 1600x840 OG card with a clean headline + brand mark.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
"author": {"slug": "<author-slug>", "name": "<display name>"}
authors: [{"slug": ". Slug must match an existing user; otherwise Ghost silently substitutes the integration owner.author: (numeric). Resolve slug -> id once and cache.author: field of the generated file.7e. Tags
"tags": ["How To", "n8n"]
How To, Tutorial, Comparison, What Is) + topic tags (your tool/category names).7f. Build the metadata bundle
tmp/blog-drafts/, then run theparams.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
}
publish: true--publish; publish_at (ISO-UTC) for --publish-at (mutually exclusive).
python3 scripts/seo-blog-writer/build-metadata.py "<slug>"
7g. Pre-publish bundle validation
python3 scripts/seo-blog-writer/validate-bundle.py "<slug>"
7h. Refresh metadata snapshot
python3 scripts/seo-blog-writer/refresh-meta.py "<slug>" "<format>"
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)
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.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
--max-links (default 6), priority-sorted from the glossary.user-agent won't match agent, @scope/ai-seo-mcp won't match mcp).data-definition attribute on each link for the tooltip.skills/seo-blog-writer/references/decorate.js into your theme bundle (or paste it inline in a tag in your site ) once. It's self-contained, ~1 KB, no dependencies, and skips itself on /glossary/* pages.
Step 8 — Publish via the platform adapter
, , ) and writes the post to its target platform.
Adapter A — Ghost (Admin API)
/ghost/api/admin/posts/.
Env var
Source
Shape
GHOST_URLYour Ghost site URL
https://blog.example.com (no trailing slash)
GHOSTADMINKEYGhost admin -> Settings -> Integrations -> (your integration) -> Admin API Key
<24-hex>:<64-hex> combined
curl -sS "$GHOST_URL/ghost/api/admin/site/" | head -c 80
[ -n "$GHOST_URL" ] && [ -n "$GHOST_ADMIN_KEY" ] && echo "keys present" || echo "MISSING"
python3 scripts/seo-blog-writer/ghost-upload-image.py "<image-path>"
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.pip install requests pyjwt. PyJWT 2.x required.
Adapter B — WordPress (REST API)
/wp-json/wp/v2/.
Env var
Source
Shape
WP_URLYour WordPress site URL
https://blog.example.com (no trailing slash)
WP_USERThe WP username the app password belongs to
admin
WPAPPPASSWORDProfile -> Application Passwords -> new -> "seo-blog-writer"
xxxx xxxx xxxx xxxx xxxx xxxx
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"
python3 scripts/seo-blog-writer/wp-upload-image.py "<image-path>"
python3 scripts/seo-blog-writer/publish-wordpress.py "<slug>"
featuredmedia in the post payload is a media id, not a URL. Upload the feature image first, capture the id, then set post["featuredmedia"] = . in content only if the user has the unfilteredhtml capability (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 into wphead.
Adapter C — Static-site (file output)
python3 scripts/seo-blog-writer/publish-static.py "<slug>" "<out-dir>"
— 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 }}
Adapter D — bring-your-own
— body HTML, post-scrub, post-audit. — JSON-LD blocks to inject in . — 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).
Step 8b. Report back to the user
if published; admin edit URL if draft).
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
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
--publish-at to schedule. Without it the post lands as draft (default) or live (--publish).blog-figure-svg skill for SVG charts, taxonomies, and flow diagrams.blog-topic-research skill to validate a topic has real demand signals before drafting.
Failure modes
Symptom
Adapter
Cause
Fix
401 UnauthorizedGhost / WordPress
Key expired / wrong key / wrong app-password
Regenerate the integration / app password
Ghost
422 Validation failed: Value in [posts.html] cannot be blankGhost
Missing
?source=htmlAdd the query param
Ghost
422 with featureimagealt in messageGhost
Alt text >191 chars
Trim to <=191; Step 7g asserts this
404 on slug after publishany
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 PUTGhost
Stale
updated_atRe-GET to refresh, retry
Author silently substituted
Ghost / WordPress
Author slug doesn't exist / user lacks
publish_postsCreate 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
codeinjectionhead set to ; echo current updatedat to avoid 409
WordPress strips
from bodyWordPress
User lacks
unfiltered_htmlMove 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.
Maintenance scripts
# 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"
.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.Ready to use publishing-skills?