/ghost-blog
Manage Ghost CMS blogs through the Content and Admin APIs. Use for reading, creating, editing, publishing, or scheduling posts and pages, uploading images, and managing tags, members, and newsletters.
$ npx -y skills add georgeguimaraes/claude-code-ghost --skill ghost-blog --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/ghost-blog
Context preview
The summary Claude sees to decide when to auto-load this skill.
Manage Ghost CMS blogs through the Content and Admin APIs. Use for reading, creating, editing, publishing, or scheduling posts and pages, uploading images, and managing tags, members, and newsletters.
SKILL.md
ghost-blog.SKILL.mdname: ghost-blog
description: "Manage Ghost CMS blogs through the Content and Admin APIs. Use for reading, creating, editing, publishing, or scheduling posts and pages, uploading images, and managing tags, members, and newsletters."
Ghost Blog Management
Interact with the user's Ghost blog using the Content API (read-only, public data) and Admin API (full read/write access).
Environment Variables
| Variable | Purpose | |----------|---------| | `GHOST_API_URL` | Base URL of the Ghost instance (e.g. `https://myblog.ghost.io`) | | `GHOST_CONTENT_API_KEY` | Content API key for read-only public access | | `GHOST_ADMIN_API_KEY` | Admin API key in `{id}:{secret}` format for full access |
These are created in Ghost Admin under Settings > Integrations > Custom Integration.
The `ghost_api.py` Module
All operations use the `Ghost` class from `ghost_api.py` (stdlib only, no dependencies). It handles JWT auth, JSON serialization, and HTTP requests. The module lives in the same directory as this skill file.
Requires [uv](https://docs.astral.sh/uv/). Resolve `GHOST_SKILL_DIR` to the absolute directory containing this `SKILL.md`, wherever the skill is installed. Both Python helpers live beside it. Use that path rather than the working directory:
GHOST_SKILL_DIR="/absolute/path/to/ghost-blog"
PYTHONPATH="$GHOST_SKILL_DIR" uv run --no-project python - << 'PY'
from ghost_api import Ghost
g = Ghost()
# ... operations here ...
PY
API Methods
| Method | Signature | Description | |--------|-----------|-------------| | `g.get(path, **params)` | Returns dict | GET request, params become query string | | `g.post(path, data, **params)` | Returns dict | POST with JSON body | | `g.put(path, data, **params)` | Returns dict | PUT with JSON body | | `g.delete(path)` | Returns None | DELETE request | | `g.upload(file_path, ref=None)` | Returns URL string | Multipart image upload | | `g.unsplash_search(query, orientation="landscape", per_page=10)` | Returns list of dicts | Search Unsplash photos | | `g.unsplash_caption(photo_id=None, user_name=None, user_username=None)` | Returns HTML string | Build Unsplash attribution caption | | `g.set_unsplash_feature_image(post_id, photo_id)` | Returns post dict | Set feature image from Unsplash photo ID |
**Path convention**: paths start with `content/` or `admin/` (e.g. `content/posts`, `admin/posts/abc123`). Auth is handled automatically based on prefix.
Common Operations
List Posts
# Public posts
posts = g.get("content/posts", include="tags,authors", limit=15)
# All posts including drafts
posts = g.get("admin/posts", include="tags,authors", formats="html", limit=15)
for p in posts["posts"]:
date = (p.get("published_at") or "(draft)")[:10]
tags = ", ".join(t["name"] for t in p.get("tags", []))
print(f" {date} {p['title']} {tags}")
print(f"Total: {posts['meta']['pagination']['total']}")Filter and Search Posts
Pass `filter` as a query param using NQL syntax:
# By tag
posts = g.get("content/posts", filter="tag:my-tag", include="tags")
# Drafts only (Admin API)
drafts = g.get("admin/posts", filter="status:draft", formats="html")
# Last 7 days
recent = g.get("admin/posts", filter="published_at:>now-7d", formats="html")
# Combined: published + specific tag (+ is AND, comma is OR)
posts = g.get("admin/posts", filter="status:published+tag:news", formats="html")**NQL operators**: `:` (equals), `-` (not), `>` `>=` `<` `<=` (comparison), `~` (contains), `[a,b]` (in), `+` (AND), `,` (OR), `()` (grouping). Wrap dates/special chars in single quotes.
Read a Single Post
# By ID
post = g.get("admin/posts/POST_ID", formats="html", include="tags,authors")["posts"][0]
# By slug (Content API)
post = g.get("content/posts/slug/my-post-slug", include="tags,authors")["posts"][0]Create a Post
Use `g.create_post()` which automatically adds `source=html` when HTML content is present (Ghost v5+ requires this to convert HTML to its internal Lexical format; without it, content will be empty):
post = g.create_post({
"title": "My New Post",
"html": "<p>Post content in HTML.</p>",
"status": "draft",
"tags": [{"name": "Tag Name"}],
"custom_excerpt": "A short excerpt.",
})
print(f"Created: {post['title']} (ID: {post['id']})")**Status options**: `draft` (default), `published`, `scheduled` (requires `published_at`).
Tags that don't exist are created automatically. To preserve raw HTML blocks, wrap in `<!--kg-card-begin: html-->` and `<!--kg-card-end: html-->`.
Update a Post
Use `g.update_post()` which fetches `updated_at` automatically and adds `source=html` when HTML content is present. Tags and authors are **replaced entirely** on update, so send the complete desired list.
post = g.update_post("POST_ID", {
"title": "Updated Title",
"html": "<p>Updated content.</p>",
})If you already have `updated_at` from a previous GET, pass it to skip the extra request:
post = g.update_post("POST_ID", {
"html": "<p>Updated content.</p>",
}, updated_at=existing_post["updated_at"])Publish a Draft
post = g.get("admin/posts/POST_ID")["posts"][0]
g.put("admin/posts/POST_ID",
{"posts": [{"status": "published", "updated_at": post["updated_at"]}]})Schedule a Post
post = g.get("admin/posts/POST_ID")["posts"][0]
g.put("admin/posts/POST_ID",
{"posts": [{
"status": "scheduled",
"published_at": "2026-03-15T11:00:00.000Z",
"updated_at": post["updated_at"],
}]})Delete a Post
**Always confirm with the user before deleting.**
g.delete("admin/posts/POST_ID")Upload an Image
url = g.upload("/path/to/image.jpg")
print(url) # https://myblog.ghost.io/content/images/2026/02/image.jpgSupported formats: JPEG, PNG, GIF, WEBP, SVG.
Insert an Image into Post Content
After uploading, use this HTML to embed
Read more
name: ghost-blog description: "Manage Ghost CMS blogs through the Content and Admin APIs. Use for reading, creating, editing, publishing, or scheduling posts and pages, uploading images, and managing tags, members, and newsletters."
Ghost Blog Management
Interact with the user's Ghost blog using the Content API (read-only, public data) and Admin API (full read/write access).
Environment Variables
| Variable | Purpose | |----------|---------| | `GHOST_API_URL` | Base URL of the Ghost instance (e.g. `https://myblog.ghost.io`) | | `GHOST_CONTENT_API_KEY` | Content API key for read-only public access | | `GHOST_ADMIN_API_KEY` | Admin API key in `{id}:{secret}` format for full access |
These are created in Ghost Admin under Settings > Integrations > Custom Integration.
The `ghost_api.py` Module
All operations use the `Ghost` class from `ghost_api.py` (stdlib only, no dependencies). It handles JWT auth, JSON serialization, and HTTP requests. The module lives in the same directory as this skill file.
Requires [uv](https://docs.astral.sh/uv/). Resolve `GHOST_SKILL_DIR` to the absolute directory containing this `SKILL.md`, wherever the skill is installed. Both Python helpers live beside it. Use that path rather than the working directory:
GHOST_SKILL_DIR="/absolute/path/to/ghost-blog" PYTHONPATH="$GHOST_SKILL_DIR" uv run --no-project python - << 'PY' from ghost_api import Ghost g = Ghost() # ... operations here ... PY
API Methods
| Method | Signature | Description | |--------|-----------|-------------| | `g.get(path, **params)` | Returns dict | GET request, params become query string | | `g.post(path, data, **params)` | Returns dict | POST with JSON body | | `g.put(path, data, **params)` | Returns dict | PUT with JSON body | | `g.delete(path)` | Returns None | DELETE request | | `g.upload(file_path, ref=None)` | Returns URL string | Multipart image upload | | `g.unsplash_search(query, orientation="landscape", per_page=10)` | Returns list of dicts | Search Unsplash photos | | `g.unsplash_caption(photo_id=None, user_name=None, user_username=None)` | Returns HTML string | Build Unsplash attribution caption | | `g.set_unsplash_feature_image(post_id, photo_id)` | Returns post dict | Set feature image from Unsplash photo ID |
**Path convention**: paths start with `content/` or `admin/` (e.g. `content/posts`, `admin/posts/abc123`). Auth is handled automatically based on prefix.
Common Operations
List Posts
# Public posts
posts = g.get("content/posts", include="tags,authors", limit=15)
# All posts including drafts
posts = g.get("admin/posts", include="tags,authors", formats="html", limit=15)
for p in posts["posts"]:
date = (p.get("published_at") or "(draft)")[:10]
tags = ", ".join(t["name"] for t in p.get("tags", []))
print(f" {date} {p['title']} {tags}")
print(f"Total: {posts['meta']['pagination']['total']}")Filter and Search Posts
Pass `filter` as a query param using NQL syntax:
# By tag
posts = g.get("content/posts", filter="tag:my-tag", include="tags")
# Drafts only (Admin API)
drafts = g.get("admin/posts", filter="status:draft", formats="html")
# Last 7 days
recent = g.get("admin/posts", filter="published_at:>now-7d", formats="html")
# Combined: published + specific tag (+ is AND, comma is OR)
posts = g.get("admin/posts", filter="status:published+tag:news", formats="html")**NQL operators**: `:` (equals), `-` (not), `>` `>=` `<` `<=` (comparison), `~` (contains), `[a,b]` (in), `+` (AND), `,` (OR), `()` (grouping). Wrap dates/special chars in single quotes.
Read a Single Post
# By ID
post = g.get("admin/posts/POST_ID", formats="html", include="tags,authors")["posts"][0]
# By slug (Content API)
post = g.get("content/posts/slug/my-post-slug", include="tags,authors")["posts"][0]Create a Post
Use `g.create_post()` which automatically adds `source=html` when HTML content is present (Ghost v5+ requires this to convert HTML to its internal Lexical format; without it, content will be empty):
post = g.create_post({
"title": "My New Post",
"html": "<p>Post content in HTML.</p>",
"status": "draft",
"tags": [{"name": "Tag Name"}],
"custom_excerpt": "A short excerpt.",
})
print(f"Created: {post['title']} (ID: {post['id']})")**Status options**: `draft` (default), `published`, `scheduled` (requires `published_at`).
Tags that don't exist are created automatically. To preserve raw HTML blocks, wrap in `<!--kg-card-begin: html-->` and `<!--kg-card-end: html-->`.
Update a Post
Use `g.update_post()` which fetches `updated_at` automatically and adds `source=html` when HTML content is present. Tags and authors are **replaced entirely** on update, so send the complete desired list.
post = g.update_post("POST_ID", {
"title": "Updated Title",
"html": "<p>Updated content.</p>",
})If you already have `updated_at` from a previous GET, pass it to skip the extra request:
post = g.update_post("POST_ID", {
"html": "<p>Updated content.</p>",
}, updated_at=existing_post["updated_at"])Publish a Draft
post = g.get("admin/posts/POST_ID")["posts"][0]
g.put("admin/posts/POST_ID",
{"posts": [{"status": "published", "updated_at": post["updated_at"]}]})Schedule a Post
post = g.get("admin/posts/POST_ID")["posts"][0]
g.put("admin/posts/POST_ID",
{"posts": [{
"status": "scheduled",
"published_at": "2026-03-15T11:00:00.000Z",
"updated_at": post["updated_at"],
}]})Delete a Post
**Always confirm with the user before deleting.**
g.delete("admin/posts/POST_ID")Upload an Image
url = g.upload("/path/to/image.jpg")
print(url) # https://myblog.ghost.io/content/images/2026/02/image.jpgSupported formats: JPEG, PNG, GIF, WEBP, SVG.
Insert an Image into Post Content
After uploading, use this HTML to embed
A skill for managing Ghost blogs from Codex, Claude Code, and other agents that support Agent Skills.

