Construct and manage Ghost CMS blog posts using Lexical JSON format via the ghost-mcp server. Use this skill whenever creating, editing, reading, or managing content on a Ghost blog. Triggers include: any mention of Ghost, blog post, Lexical, draft, publish, or any request involving blog content management. This skill covers both the Lexical document format and the MCP tool API for interacting with Ghost.
This skill teaches you how to construct valid Ghost Lexical JSON documents and interact with a Ghost blog through the ghost-mcp server tools.
Prerequisites: The ghost-mcp server must be configured and running. If MCP tools are unavailable, remind the user to run
bunx @perezd/ghost-mcp authto set up their Ghost site URL and Admin API key.
The ghost-mcp server exposes five tools. All content is exclusively in Lexical JSON format.
Query and filter posts. Returns summaries (id, title, slug, status, dates, excerpt) plus pagination metadata.
Parameters:
filter (string, optional) — NQL filter string. Examples: status:draft, tag:news+status:published, author:dereklimit (number, default 15) — Posts per pagepage (number, default 1) — Page numberorder (string, optional) — Sort order. Example: published_at desc, updated_at ascCommon filter patterns:
status:drafttag:engineering+status:publishedorder: "updated_at desc" with a small limitRetrieve a single post with full Lexical content. Accepts either a 24-character hex ID or a slug.
Parameters:
id_or_slug (string, required) — Post ID (24-char hex like 6721a3b8e4f5c6d7e8f90a1b) or URL slug (like my-first-post)Returns: Full post object including lexical field containing the JSON document as a string. Always parse the lexical field with JSON.parse() before reading or modifying content.
Create a new post. Defaults to draft status.
Parameters:
title (string, required) — Post titlelexical (string, optional) — Content as a JSON string of a Lexical document. Must be JSON.stringify()'d.status (enum: draft | published | scheduled, default draft) — Post statustags (string[], optional) — Tag names to assign. Tags are created automatically if they don't exist.custom_excerpt (string, optional) — A short summary/teaser for the post. Displayed in post lists, social cards, and SEO previews. Max 300 characters recommended.Important: The lexical value must be a JSON string, not a raw object. Always wrap the Lexical document with JSON.stringify().
Large document strategy: If the Lexical JSON is very large (e.g., 30K+ characters for a long-form article), the MCP tool call may hit parameter size limits. In that case, use a two-step approach:
create_post with just the title, tags, status, and custom_excerpt (no lexical)update_post with the post's id, updated_at, and the full lexical contentThis separates metadata from content and avoids oversized single calls.
Escape hatch — direct MCP server invocation: If both create_post and update_post tool calls fail due to parameter size limits, you can invoke the ghost-mcp server directly via its stdio transport as a last resort. Write a small script that sends JSON-RPC messages to the server process:
// Run with: bun run create-large-post.ts
import jwt from "jsonwebtoken";
// Or use the MCP stdio protocol directly:
// 1. Spawn: bunx @perezd/ghost-mcp serve
// 2. Send JSON-RPC initialize handshake
// 3. Send notifications/initialized
// 4. Send tools/call with your create_post or update_post arguments
// 5. Read the JSON-RPC response from stdout
// Alternatively, call the Ghost Admin API directly:
const config = await Bun.file(
`${process.env.HOME}/.ghost-mcp/config.json`
).json();
const site = config.sites[config.default];
const [id, secret] = site.apiKey.split(":");
const token = jwt.sign({}, Buffer.from(secret, "hex"), {
keyid: id,
algorithm: "HS256",
expiresIn: "5m",
audience: "/admin/",
});
const lexical = await Bun.file("my-large-post.json").text();
const res = await fetch(
`${config.default}/ghost/api/admin/posts/`,
{
method: "POST",
headers: {
Authorization: `Ghost ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
posts: [
{
title: "My Post",
lexical: lexical,
status: "draft",
custom_excerpt: "My excerpt",
},
],
}),
}
);
const data = await res.json();
console.log(data.posts[0].id);
This bypasses MCP tool parameter limits entirely by going straight to the Ghost Admin API. Use the same JWT signing approach that ghost-mcp uses internally. This should only be necessary for exceptionally large documents.
Modify an existing post. Requires updated_at for optimistic locking — Ghost rejects updates if the timestamp doesn't match, preventing overwrites of concurrent edits.
Parameters:
id (string, required) — Post ID (24-char hex)updated_at (string, required) — The current updated_at value from the post. Obtain this from get_post or list_posts immediately before updating.title (string, optional) — New titlelexical (string, optional) — New content as Lexical JSON string. Replaces the entire document — there is no partial/append mode.status (enum: draft | published | scheduled, optional) — New statustags (string[], optional) — New tag names. Replaces all existing tags.custom_excerpt (string, optional) — New excerpt text. Replaces the existing excerpt.Collision detection workflow:
get_post to get the current post (and its updated_at)update_post with the updated_at value from step 1Permanently delete a post.
Parameters:
id (string, required) — Post ID (24-char hex)Warning: This is irreversible. Always confirm with the user before deleting.
All Ghost content uses a Lexical JSON document format. The complete reference is in references/lexical-format-guide.md. Below are the essential patterns for constructing documents.
Every Lexical document has this wrapper:
{
"root": {
"children": [],
"direction": "ltr",
"format": "",
"indent": 0,
"type": "root",
"version": 1
}
}
All content nodes go inside root.children.
{
"type": "paragraph",
"children": [
{
"type": "extended-text",
"text": "Your paragraph text here.",
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
A paragraph can contain multiple extended-text and link children to mix formatting and links inline.
{
"type": "extended-heading",
"tag": "h2",
"children": [
{
"type": "extended-text",
"text": "Section Title",
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
Tags: h1 through h6. Use h2 for main sections, h3 for subsections.
The format field on extended-text nodes uses bitwise flags:
| Value | Style |
|---|---|
| 0 | Plain |
| 1 | Italic |
| 2 | Bold |
| 3 | Bold + Italic |
| 4 | Underline |
| 8 | Strikethrough |
| 16 | Inline code |
Combine with addition: bold + code = 18.
To mix formatting within a paragraph, use multiple extended-text children with different format values.
Links are inline children within paragraphs:
{
"type": "link",
"url": "https://example.com",
"children": [
{
"type": "extended-text",
"text": "link text",
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1,
"rel": null,
"target": null,
"title": null
}
For bare URLs (no custom text), use "children": [].
{
"type": "list",
"listType": "bullet",
"start": 1,
"tag": "ul",
"children": [
{
"type": "listitem",
"value": 1,
"children": [
{
"type": "extended-text",
"text": "First item",
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
For ordered lists: "listType": "number", "tag": "ol". List item value fields should be sequential (1, 2, 3...).
{
"type": "image",
"version": 1,
"src": "https://example.com/photo.jpg",
"width": 1200,
"height": 800,
"title": "",
"alt": "Descriptive alt text",
"caption": "",
"cardWidth": "regular",
"href": ""
}
cardWidth options: regular, wide, full. Captions accept HTML.
{
"type": "codeblock",
"version": 1,
"code": "const greeting = 'hello';",
"language": "javascript",
"caption": ""
}
Use the appropriate language identifier for syntax highlighting.
Rich link previews. Ghost auto-fetches metadata when you provide a URL:
{
"type": "bookmark",
"version": 1,
"url": "https://example.com/article",
"metadata": {
"icon": "",
"title": "Article Title",
"description": "Article description",
"author": "",
"publisher": "",
"thumbnail": ""
},
"caption": ""
}
When creating bookmarks, you can provide just the url and minimal metadata — Ghost will enrich it.
Newsletter subscription forms:
{
"type": "signup",
"version": 1,
"alignment": "left",
"backgroundColor": "#F0F0F0",
"backgroundImageSrc": "",
"backgroundSize": "cover",
"textColor": "#000000",
"buttonColor": "accent",
"buttonTextColor": "#FFFFFF",
"buttonText": "Subscribe",
"disclaimer": "<span style=\"white-space: pre-wrap;\">No spam. Unsubscribe anytime.</span>",
"header": "<span style=\"white-space: pre-wrap;\">Sign up for updates</span>",
"labels": [],
"layout": "wide",
"subheader": "",
"successMessage": "Email sent! Check your inbox to complete your signup.",
"swapped": false
}
Collapsible content sections. Both heading and content accept HTML:
{
"type": "toggle",
"version": 1,
"heading": "<span style=\"white-space: pre-wrap;\">Click to expand</span>",
"content": "<p>Hidden content here</p>"
}
{
"type": "extended-quote",
"children": [
{
"type": "extended-text",
"text": "Quoted text goes here.",
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
A container node like paragraphs — children can include extended-text and link nodes with mixed formatting.
A styled tangent or editorial aside:
{
"type": "aside",
"children": [
{
"type": "extended-text",
"text": "An editorial tangent or side note.",
"format": 0,
"detail": 0,
"mode": "normal",
"style": "",
"version": 1
}
],
"direction": "ltr",
"format": "",
"indent": 0,
"version": 1
}
Same structure as a paragraph but rendered with distinct visual styling.
Highlighted callout boxes with optional emoji and background color. The calloutText field accepts HTML:
{
"type": "callout",
"version": 1,
"calloutText": "<p><strong>Note</strong> — This is important context.</p>",
"calloutEmoji": "",
"backgroundColor": "grey"
}
backgroundColor options include: grey, white, blue, green, yellow, red, pink, purple, and accent.
oEmbed content (YouTube, Twitter, etc.). Ghost fetches the embed metadata from the URL:
{
"type": "embed",
"version": 1,
"url": "https://www.youtube.com/watch?v=VIDEO_ID",
"embedType": "video",
"html": "<iframe ...></iframe>",
"metadata": {
"title": "Video Title",
"author_name": "Channel Name",
"author_url": "https://www.youtube.com/@channel",
"type": "video",
"height": 113,
"width": 200,
"version": "1.0",
"provider_name": "YouTube",
"provider_url": "https://www.youtube.com/",
"thumbnail_height": 360,
"thumbnail_width": 480,
"thumbnail_url": "https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg",
"html": "<iframe ...></iframe>"
},
"caption": ""
}
When creating embeds, provide the url and embedType at minimum — Ghost enriches the metadata.
Raw HTML injection with optional visibility controls for member segmentation:
{
"type": "html",
"version": 1,
"html": "<div>Custom HTML content here</div>",
"visibility": {
"web": {
"nonMember": true,
"memberSegment": "status:free,status:-free"
},
"email": {
"memberSegment": "status:free,status:-free"
}
}
}
The visibility field is optional. When present, it controls who sees the content:
web.nonMember — whether non-members see this block on the websiteweb.memberSegment / email.memberSegment — NQL segment filter (e.g., status:free for free members, status:-free for paid members)If visibility is omitted, the HTML block is shown to everyone.
{
"type": "linebreak",
"version": 1
}
Every container node (paragraph, heading, list, listitem, extended-quote, aside) requires all of these:
type — node type identifierversion — always 1children — array of child nodesdirection — "ltr" for left-to-rightformat — "" (empty string for container nodes)indent — 0 for no indentationEvery extended-text node requires:
type — "extended-text"text — the text contentformat — bitwise format flag (0 for plain)detail — 0mode — "normal"style — ""version — 1Card nodes (image, codeblock, bookmark, signup, toggle, callout, embed, html, linebreak) are simpler — they require type, version, and their specific fields.
root.children in reading orderJSON.stringify(doc)create_post or update_post as the lexical parameterget_post to retrieve current contentlexical field: JSON.parse(post.lexical)root.children to find and modify nodesupdate_post with the post's current updated_atA well-formed blog post typically follows this node sequence in root.children:
extended-heading (h2) — first sectionextended-quote for blockquotes, aside for editorial tangentslist nodes for enumerationsimage, embed, or codeblock nodes as neededcallout cards for highlighted notes or warningslinebreak between major sectionshtml card for author bio or custom blocks (with visibility controls)signup card at the endThe list_posts tool supports Ghost's NQL (Neon Query Language) for filtering:
status:draft, tag:newstag:news+status:published (AND), tag:news,tag:tech (OR)-status:draft (NOT draft)created_at:>'2025-01-01'author:derekCommon queries:
status:drafttag:my-tag+status:publishedorder: "published_at desc" with limit: 5