ghost-blog-writer

Research, draft, scrub, AI-SEO-audit, and publish a blog post to any Ghost CMS site via the Admin API. Takes a topic (typically a long-tail query like 'how to fix X', 'how to connect X to Y', 'X vs Y', 'what is X') and produces a complete post: structured for SEO and AI-citation (H2 headings matching query phrasing, TL;DR block, FAQ section, JSON-LD schema), scrubbed of LLM-tell typography, and posted via the Ghost Admin API. Trigger when the user says: 'write a blog post on X', 'publish a Ghost article on X', 'draft a blog entry about X', or any request to create editorial content for a Ghost blog.

Audits

Pending

Install

openclaw skills install ghost-blog-writer

ghost-blog-writer

End-to-end pipeline for shipping a single long-tail blog post to a Ghost CMS site: 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 skill takes one required argument: the topic. Optional flags control publish state.

/ghost-blog-writer <topic>
/ghost-blog-writer <topic> --publish                    # publish live (default: save as draft)
/ghost-blog-writer <topic> --publish-at <ISO-UTC>       # schedule for future publish
/ghost-blog-writer <topic> --angle "<angle>"            # narrow the angle

Default state is draft — the post lands in Ghost admin 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.


Before you start — preflight

This skill targets any Ghost CMS instance (self-hosted or Ghost(Pro)). It uses the Admin API over HTTPS. No Docker, no SSH — just authenticated POST to /ghost/api/admin/posts/.

Run these three checks before Step 0 and stop if any fails:

# 1. Target URL is up — replace <ghost-url> with your site
curl -sS https://<your-ghost-site>/ghost/api/admin/site/ | head -c 80
# Expected: {"site":{"title":"...", ...

# 2. The two required env vars are set (see "Credentials" below)
[ -n "$GHOST_URL" ] && [ -n "$GHOST_ADMIN_KEY" ] && echo "keys present" || echo "MISSING"

Credentials

Two values are required. Get them from Ghost admin -> Settings -> Integrations -> (your integration):

Env varSourceShape
GHOST_URLYour Ghost site URLhttps://blog.example.com (no trailing slash)
GHOST_ADMIN_KEYIntegration -> Admin API Key<24-hex>:<64-hex> combined

The Admin Key is one string of the form <id>:<secret>, separated by a colon. Step 8 splits it to build the JWT.

Set them in your shell before invoking the skill:

export GHOST_URL="https://blog.example.com"
export GHOST_ADMIN_KEY="64a..."   # full <id>:<secret> from Ghost admin

Never commit these values. Keep them in a .env file (gitignored) or your shell profile.


Step 0 — Parse and classify the topic

The topic is the one thing the skill cannot invent. It must arrive as an argument.

ShapeExampleTreatment
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:

  1. 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.
  2. 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.
  3. 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.

  1. What is the exact query intent? (one sentence from 1a)
  2. What is the direct answer? (one to two sentences — the lead paragraph in compressed form)
  3. What's the canonical primary source? (vendor doc, GitHub issue, official changelog — at least one URL)
  4. 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.
  5. 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 quotes
  • tmp/blog-drafts/<slug>.draft.html — written in Step 3
  • tmp/blog-drafts/<slug>.codeinjection-head.html — written in Step 7b
  • tmp/blog-drafts/<slug>.payload.json — written in Step 7f

Step 2 — Pick the format and length band

Each query type maps to a structural template:

FormatLength 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
migration1200-1800
release-recap800-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 (Ghost accepts HTML via ?source=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 (Ghost emits the post title as H1).

Link policy — internal vs. outbound, follow vs. nofollow

Destinationrel attribute
Your own blog (other posts on $GHOST_URL)none — internal, follow
Anything else (vendor docs, GitHub, news, social, all third-party)rel="nofollow noopener"

Do not use target="_blank" — most Ghost themes handle outbound link UX themselves.

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/H3 phrased like queries. ## How to fix the "ECONNREFUSED" error in n8n beats ## Fixing the connection error.
  • 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".
  • 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

Run the audit against the draft, checking each pass:

  1. Structure pass — does the lead answer the query in the first paragraph; do H2s match query phrasing; is each section self-contained.
  2. Authority pass — at least one cited primary source (vendor doc / GitHub issue / changelog) on a relevant noun phrase.
  3. Freshness pass — current year referenced where it makes sense; version numbers are current.
  4. Schema readiness — Ghost emits 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.
  5. Long-tail coverage — does the FAQ block capture 3-6 long-tail variants of the main query.
  6. 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> carries rel="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
python3 -c "
import re, sys, pathlib
from urllib.parse import urlparse
html = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
import os
ghost_host = urlparse(os.environ.get('GHOST_URL', '')).hostname or ''
internal = {ghost_host, f'www.{ghost_host}' if ghost_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
    host = urlparse(href.group(1)).hostname or ''
    if host and host 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 every post, but recommended for posts over 800 words. Rule of thumb: 1 figure per ~500 body words.

For figure generation (SVG flow diagrams, comparison charts, taxonomy diagrams) see the companion blog-figure-svg skill — it generates accessible SVG figures with consistent styling and ships them through Ghost's image upload endpoint.

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.

Upload images to Ghost CDN

python3 - <<'PY'
import os, sys, pathlib, datetime, requests, jwt

GHOST_URL = os.environ['GHOST_URL'].rstrip('/')
key = os.environ['GHOST_ADMIN_KEY']
kid, secret = key.split(':', 1)

iat = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
token = jwt.encode(
    {'iat': iat, 'exp': iat + 5 * 60, 'aud': '/admin/'},
    bytes.fromhex(secret),
    algorithm='HS256',
    headers={'kid': kid, 'alg': 'HS256', 'typ': 'JWT'},
)

img_path = pathlib.Path(sys.argv[1])
with img_path.open('rb') as f:
    r = requests.post(
        f"{GHOST_URL}/ghost/api/admin/images/upload/",
        headers={'Authorization': f'Ghost {token}'},
        files={'file': (img_path.name, f, 'image/png')},
        data={'purpose': 'image'},
    )
r.raise_for_status()
print(r.json()['images'][0]['url'])
PY
# Pass the image path as the only argument

Splice figure tags into the draft

<figure>
  <img src="<uploaded-png-url>" 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> (with rel="nofollow noopener" for external), <em>.

Step 7 — Build the Ghost post payload

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 numbersn8n-1-45-2-fix goes stale; n8n-http-401-fix does 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 for codeinjection_head (FAQPage + BreadcrumbList + HowTo)

Ghost emits 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 Ghost gotcha: Ghost converts the source HTML to its Lexical rich-text format on save, and the deserialiser silently drops <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 the post's codeinjection_head field (stored verbatim and rendered into <head> via {{ghost_head}}).

Never append <script type="application/ld+json"> to the body HTML. Build it once via this step into <slug>.codeinjection-head.html; Step 7f wires it into the payload's codeinjection_head field.

If the live page is missing schema after publish, the recovery is a follow-up PUT to /posts/<id>/?source=html setting codeinjection_foot (or codeinjection_head) to the contents of <slug>.codeinjection-head.html. Echo the post's current updated_at to avoid 409.

# Args: slug, headline, format, primary-tag-name, ghost-url
python3 - "<slug>" "<headline>" "<format>" "<primary-tag>" "$GHOST_URL" <<'PY'
import json, re, pathlib, sys
slug, headline, fmt, primary_tag, ghost_url = sys.argv[1:6]
ghost_url = ghost_url.rstrip('/')
draft = pathlib.Path(f"tmp/blog-drafts/{slug}.draft.html")
html = draft.read_text(encoding='utf-8')

def slugify(s):
    return re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-')

blocks = []

# 1. BreadcrumbList — always
blocks.append(("BreadcrumbList", {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
        {"@type":"ListItem","position":1,"name":"Home","item":f"{ghost_url}/"},
        {"@type":"ListItem","position":2,"name":primary_tag,
         "item":f"{ghost_url}/tag/{slugify(primary_tag)}/"},
        {"@type":"ListItem","position":3,"name":headline,
         "item":f"{ghost_url}/{slug}/"},
    ],
}))

# 2. FAQPage — extracted from the FAQ block
m = re.search(r'<h2[^>]*>\s*FAQ\s*</h2>(.*)$', html, flags=re.S|re.I)
qa = []
if m:
    pairs = re.findall(r'<h3[^>]*>(.*?)</h3>\s*<p[^>]*>(.*?)</p>', m.group(1), flags=re.S|re.I)
    qa = [{"@type":"Question",
           "name": re.sub(r'<[^>]+>','',q).strip(),
           "acceptedAnswer":{"@type":"Answer","text": re.sub(r'<[^>]+>','',a).strip()}}
          for q, a in pairs]
if qa:
    blocks.append(("FAQPage", {"@context":"https://schema.org","@type":"FAQPage","mainEntity":qa}))
else:
    print("WARN: no FAQ Q/A pairs found — Step 3 requires an FAQ block", file=sys.stderr)

# 3. HowTo — procedural formats with >=3 step-shaped H2s
if fmt in {"how-to-fix", "how-to-connect", "how-to-automate", "use-case", "migration"}:
    h2s = re.findall(r'<h2[^>]*>(.*?)</h2>', html)
    proc = [re.sub(r'<[^>]+>','',h).strip() for h in h2s
            if re.match(r'^\s*(Step|How to|Fix|Configure|Set up|Install|Create|Add|Enable)',
                        re.sub(r'<[^>]+>','',h).strip(), flags=re.I)]
    if len(proc) >= 3:
        blocks.append(("HowTo", {"@context":"https://schema.org","@type":"HowTo",
                                 "name": headline,
                                 "step":[{"@type":"HowToStep","name":s,"position":i+1}
                                         for i,s in enumerate(proc)]}))

ci = "\n".join(f'<script type="application/ld+json">{json.dumps(b, ensure_ascii=False)}</script>'
               for _, b in blocks)
pathlib.Path(f"tmp/blog-drafts/{slug}.codeinjection-head.html").write_text(ci, encoding='utf-8')
print(f"wrote {len(blocks)} JSON-LD block(s): {[t for t,_ in blocks]}")
PY

7c. Feature image (recommended)

Ghost displays a feature image at the top of every post and in social shares (OG image). Strongly recommended for any post you intend to promote.

You can:

  • Upload a custom image via the upload endpoint (Step 6 snippet), then reference its URL in feature_image.
  • Generate a templated title card — see the companion blog-figure-svg skill for a generator that produces 1600x840 OG cards with a clean headline + brand mark.
  • Skip it — the post will display without a hero image; OG cards in social previews will fall back to the site default.

Whatever path you pick, set both feature_image (URL) and feature_image_alt (one-line description, <=191 chars — Ghost silently caps at varchar(191)).

7d. Author byline

Every post needs an author. Use the slug of an existing Ghost user (not email, not display name).

"authors": [{"slug": "<author-slug>"}]

If the slug doesn't match a real user, Ghost silently substitutes the integration owner. Verify the response's authors[0].slug matches what you sent; if not, PUT a correction with the original updated_at.

Create authors via Ghost admin -> Staff (the API requires a separate auth flow). Capture the slug from the user's profile URL.

7e. Tags

Use tag objects with the name string (Ghost auto-creates tags that don't exist yet):

"tags": [{"name": "How To"}, {"name": "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 Ghost 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 payload

python3 - <<'PY'
import json, os, pathlib, sys

# Edit per post:
SLUG = "<slug>"
HEADLINE = "<headline>"
TAGS = [{"name": "How To"}, {"name": "n8n"}]   # first entry is the primary tag passed to 7b
AUTHOR_SLUG = "<author-slug>"
FEATURE_IMAGE = "<https://your-ghost-site/content/images/.../feature.png>"   # or ""
FEATURE_IMAGE_ALT = "<one-line alt text, <=191 chars>"
FEATURE_IMAGE_CAPTION = "<one sentence, 12-25 words, restates the post promise>"
META_TITLE = "<SEO title under 60 chars>"
META_DESCRIPTION = "<SEO description, 140-160 chars>"
CUSTOM_EXCERPT = "<dek shown on index page>"
PUBLISH_FLAG = False              # set by --publish
PUBLISH_AT_ISO = None             # set by --publish-at <iso>

html = pathlib.Path(f"tmp/blog-drafts/{SLUG}.draft.html").read_text(encoding="utf-8")

ci_path = pathlib.Path(f"tmp/blog-drafts/{SLUG}.codeinjection-head.html")
if not ci_path.exists():
    sys.exit("missing codeinjection-head file — run Step 7b before payload build")
codeinjection_head = ci_path.read_text(encoding="utf-8")

# status / published_at:
#   default              -> status="draft",     no published_at
#   --publish            -> status="published", no published_at
#   --publish-at <iso>   -> status="scheduled", published_at="<iso>" (future, UTC)
status, published_at = "draft", None
if PUBLISH_AT_ISO:
    status, published_at = "scheduled", PUBLISH_AT_ISO
elif PUBLISH_FLAG:
    status = "published"

post = {
    "title": HEADLINE,
    "slug": SLUG,
    "html": html,
    "status": status,
    "tags": TAGS,
    "authors": [{"slug": AUTHOR_SLUG}],
    "meta_title": META_TITLE,
    "meta_description": META_DESCRIPTION,
    "custom_excerpt": CUSTOM_EXCERPT,
    "codeinjection_head": codeinjection_head,
}
if FEATURE_IMAGE:
    post["feature_image"] = FEATURE_IMAGE
    post["feature_image_alt"] = FEATURE_IMAGE_ALT
    post["feature_image_caption"] = FEATURE_IMAGE_CAPTION
if published_at:
    post["published_at"] = published_at

payload = {"posts": [post]}
pathlib.Path(f"tmp/blog-drafts/{SLUG}.payload.json").write_text(
    json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print("payload written")
PY

7g. Pre-publish payload validation

Before POSTing, all of these must hold:

python3 - "<slug>" <<'PY'
import json, pathlib, re, sys
slug = sys.argv[1]
p = json.loads(pathlib.Path(f"tmp/blog-drafts/{slug}.payload.json").read_text())
post = p["posts"][0]
html = post["html"]

assert post.get("authors") and post["authors"][0].get("slug"), "authors[0].slug missing"
assert post.get("tags"), "tags array empty"

# Feature image: if set, alt text is required and capped at 191
if post.get("feature_image"):
    alt = post.get("feature_image_alt", "")
    assert alt.strip(), "feature_image_alt required when feature_image is set"
    assert len(alt) <= 191, \
        f"feature_image_alt is {len(alt)} chars; Ghost silently caps at 191 (varchar(191))"

# JSON-LD in codeinjection_head
ci = post.get("codeinjection_head", "")
assert '"@type": "FAQPage"' in ci or '"@type":"FAQPage"' in ci, \
       "FAQPage JSON-LD missing in codeinjection_head - re-run 7b"
assert '"@type": "BreadcrumbList"' in ci or '"@type":"BreadcrumbList"' in ci, \
       "BreadcrumbList JSON-LD missing in codeinjection_head - re-run 7b"

# TL;DR block check
m_first_p = re.search(r'<p\b[^>]*>(.*?)</p>', html, flags=re.S|re.I)
assert m_first_p, "no <p> in body — TL;DR check cannot run"
first_p_inner = m_first_p.group(1)
assert re.search(r'^\s*<strong>\s*TL;DR\s*:?\s*</strong>', first_p_inner, flags=re.I), \
       "first <p> must open with <strong>TL;DR:</strong>"
_t = re.sub(r'<code\b[^>]*>.*?</code>', '', first_p_inner, flags=re.S|re.I)
_t = re.sub(r'<pre\b[^>]*>.*?</pre>', '', _t, flags=re.S|re.I)
tldr_text = re.sub(r'<[^>]+>', '', _t)
tldr_text = re.sub(r'^\s*TL;DR\s*:?\s*', '', tldr_text, flags=re.I).strip()
tldr_words = len(re.findall(r"[A-Za-z0-9][A-Za-z0-9'\-]*", tldr_text))
assert 8 <= tldr_words <= 40, f"TL;DR must be 8-40 words, got {tldr_words}: {tldr_text!r}"
mid_sentence_ends = len(re.findall(r'(?<!\.)[.!?]\s+[A-Z(]', tldr_text))
assert mid_sentence_ends == 0, \
       f"TL;DR must be a single sentence; got: {tldr_text!r}"

# Scheduled posts need a future timestamp
if post.get("status") == "scheduled":
    import datetime
    pa = post.get("published_at", "")
    assert pa, "scheduled posts require published_at"
    ts = datetime.datetime.fromisoformat(pa.replace("Z","+00:00"))
    assert ts > datetime.datetime.now(datetime.timezone.utc), \
           f"scheduled published_at must be in the future, got {pa}"

# Figure caption gate: every <figure> must contain a non-empty <figcaption>
figures = re.findall(r'<figure\b[^>]*>.*?</figure>', html, flags=re.S|re.I)
uncaptioned = []
for i, fig in enumerate(figures, 1):
    cap = re.search(r'<figcaption\b[^>]*>(.*?)</figcaption>', fig, flags=re.S|re.I)
    if not cap or not re.sub(r'<[^>]+>', '', cap.group(1)).strip():
        src = re.search(r'<img[^>]*src="([^"]+)"', fig)
        uncaptioned.append(f"figure {i} ({src.group(1) if src else 'no src'})")
assert not uncaptioned, \
    "missing/empty <figcaption> on: " + ", ".join(uncaptioned)

print(f"payload OK ({len(figures)} figures, all captioned)")
PY

If any assert fires, fix and re-build before Step 8.


Step 8 — Publish via Ghost Admin API

8a. Generate a JWT and POST the post

Ghost Admin API uses short-lived JWTs (5 min) keyed by the integration's admin key. The script splits GHOST_ADMIN_KEY on the colon, signs a JWT with the secret half, and includes the id half as the kid header.

python3 - "<slug>" <<'PY'
import os, sys, json, pathlib, datetime, requests, jwt

slug = sys.argv[1]
ghost_url = os.environ['GHOST_URL'].rstrip('/')
key = os.environ['GHOST_ADMIN_KEY']
kid, secret = key.split(':', 1)

iat = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
token = jwt.encode(
    {'iat': iat, 'exp': iat + 5 * 60, 'aud': '/admin/'},
    bytes.fromhex(secret),
    algorithm='HS256',
    headers={'kid': kid, 'alg': 'HS256', 'typ': 'JWT'},
)

payload = json.loads(pathlib.Path(f"tmp/blog-drafts/{slug}.payload.json").read_text())
r = requests.post(
    f"{ghost_url}/ghost/api/admin/posts/?source=html",
    headers={
        'Authorization': f'Ghost {token}',
        'Content-Type': 'application/json',
        'Accept-Version': 'v5.0',
    },
    json=payload,
)
if not r.ok:
    print(f"FAILED {r.status_code}: {r.text}", file=sys.stderr)
    sys.exit(1)
resp = r.json()
post = resp['posts'][0]
print(json.dumps({
    'id': post['id'],
    'url': post.get('url'),
    'slug': post.get('slug'),
    'status': post.get('status'),
    'published_at': post.get('published_at'),
    'authors': [a.get('slug') for a in post.get('authors', [])],
}, indent=2))
PY

?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.

The response prints the created post's id, url, slug, and status. Capture these for verification.

Python deps: pip install requests pyjwt. PyJWT 2.x is required (1.x uses a different signature).

8b. Report back to the user

  • Draft URL or live URL (<ghost-url>/<slug>/ if published; admin preview URL if draft).
  • Ghost admin edit URL (<ghost-url>/ghost/#/editor/post/<id>).
  • 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 "$GHOST_URL/<slug>/" | head -5

# Post in RSS
curl -sS "$GHOST_URL/rss/" | grep -o "<title>[^<]*</title>" | head -5

# Post in sitemap
curl -sS "$GHOST_URL/sitemap-posts.xml" | grep "<slug>"

# OG + full schema set rendered
curl -sS "$GHOST_URL/<slug>/" | grep -o 'property="og:[^"]*"' | sort -u
curl -sS "$GHOST_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 codeinjection_head was dropped — check the Ghost admin Code Injection panel for that post.


What this skill does NOT do

  • Does not commit to git. Ghost stores posts in its own database. Only file writes happen in tmp/blog-drafts/ (gitignored).
  • Does not schedule posts by default. Pass --publish-at <ISO-UTC> to schedule one. Without the flag the post lands as draft (default) or live (--publish).
  • Does not handle member-only posts, newsletters, or email sends. Ghost's newsletter flow is manual via admin UI.
  • Does not generate figures. Use the companion blog-figure-svg skill for SVG charts, taxonomies, and flow diagrams.
  • Does not research topics from scratch. Use the companion blog-topic-research skill to validate a topic has real demand signals before drafting.

Failure modes

SymptomCauseFix
401 UnauthorizedKey expired / wrong keyRegenerate under Ghost admin -> Integrations; update GHOST_ADMIN_KEY
422 Validation failed: Value in [posts.html] cannot be blankMissing ?source=htmlAdd the query param
422 with feature_image_alt in messageAlt text >191 chars (Ghost's silent varchar(191) cap)Trim to <=191; Step 7g asserts this
404 on slug after publishPost saved as draft (default)Drafts only reachable via admin editor URL
Body shows as one HTML blobGhost fell back to plain-text modeRe-post with ?source=html
Smart quotes reappear in rendered postGhost typographer auto-conversionSettings -> Publication: turn off "Use typographer's quotes"
Wrong slugGhost auto-slugged from titlePUT /posts/<id>/ with corrected slug + current updated_at
409 Conflict on PUTStale updated_atRe-GET to refresh, retry
Author silently substituted with integration ownerAuthor slug doesn't existCreate the user in Ghost admin -> Staff; PUT correction with correct slug
Live page missing FAQPage / HowTo @type (Step 9)JSON-LD was inlined in the draft body and stripped by Ghost's Lexical conversionPUT /posts/<id>/?source=html with codeinjection_head set to <slug>.codeinjection-head.html; echo current updated_at to avoid 409

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 to Ghost.