Install
openclaw skills install ghost-blog-writerResearch, 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.
openclaw skills install ghost-blog-writerEnd-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.
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"
Two values are required. Get them from Ghost admin -> Settings -> Integrations -> (your integration):
| Env var | Source | Shape |
|---|---|---|
GHOST_URL | Your Ghost site URL | https://blog.example.com (no trailing slash) |
GHOST_ADMIN_KEY | Integration -> 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.
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.
The piece must be specific. Real version numbers, real error messages, real screenshots — not generic "best practices."
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 workflowIf you can't write one sentence describing the intent, the topic is too vague — go back to Step 0.
WebSearch("<topic>")
WebSearch("<topic> <current-year>") # force a fresh lens
Extract three structured signals from the page-1 results:
Goal: write something more specific or more current than the existing top results, not a paraphrase.
Pick 2-4 URLs from the SERP. Prioritize:
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.
Before writing, you must be able to answer all five.
If any answer is ?, keep researching or ask the user for a specific source.
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>.draft.html — written in Step 3tmp/blog-drafts/<slug>.codeinjection-head.html — written in Step 7btmp/blog-drafts/<slug>.payload.json — written in Step 7fEach 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.
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).
| Destination | rel 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.
<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.## How to fix the "ECONNREFUSED" error in n8n beats ## Fixing the connection error.rel="nofollow noopener".<h2>FAQ</h2> block — 3-6 H3 questions, each with a 1-3 sentence answer.Save to tmp/blog-drafts/<slug>.draft.html.
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:
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
Search the draft for these banned phrases and rewrite:
Rewrite every hit — do not just delete; the surrounding sentence is usually also lazy.
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).
<p> of the body, opens with <strong>TL;DR:</strong>, 8-40 words, single sentence.<p>) answers the query in 1-2 sentences.rel="nofollow noopener".<a> 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
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
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.
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
<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:
<figcaption>: <a> (with rel="nofollow noopener" for external), <em>.Headline (becomes the SEO title unless meta_title overrides):
Slug (URL fragment):
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('-')
codeinjection_head (FAQPage + BreadcrumbList + HowTo)Ghost emits Article/BlogPosting/Person/Organization schema by default. This skill adds three more for AI-citation extractability:
Home > <Primary Tag> > <Post Title>.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
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:
feature_image.blog-figure-svg skill for a generator that produces 1600x840 OG cards with a clean headline + brand mark.Whatever path you pick, set both feature_image (URL) and feature_image_alt (one-line description, <=191 chars — Ghost silently caps at varchar(191)).
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.
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).
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
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.
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).
<ghost-url>/<slug>/ if published; admin preview URL if draft).<ghost-url>/ghost/#/editor/post/<id>).--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.
tmp/blog-drafts/ (gitignored).--publish-at <ISO-UTC> to schedule one. Without the flag 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.| Symptom | Cause | Fix |
|---|---|---|
401 Unauthorized | Key expired / wrong key | Regenerate under Ghost admin -> Integrations; update GHOST_ADMIN_KEY |
422 Validation failed: Value in [posts.html] cannot be blank | Missing ?source=html | Add the query param |
422 with feature_image_alt in message | Alt text >191 chars (Ghost's silent varchar(191) cap) | Trim to <=191; Step 7g asserts this |
404 on slug after publish | Post saved as draft (default) | Drafts only reachable via admin editor URL |
| Body shows as one HTML blob | Ghost fell back to plain-text mode | Re-post with ?source=html |
| Smart quotes reappear in rendered post | Ghost typographer auto-conversion | Settings -> Publication: turn off "Use typographer's quotes" |
| Wrong slug | Ghost auto-slugged from title | PUT /posts/<id>/ with corrected slug + current updated_at |
409 Conflict on PUT | Stale updated_at | Re-GET to refresh, retry |
| Author silently substituted with integration owner | Author slug doesn't exist | Create 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 conversion | PUT /posts/<id>/?source=html with codeinjection_head set to <slug>.codeinjection-head.html; echo current updated_at to avoid 409 |
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.