Admin API writes carrying an html payload (create-post, update-post, create-page) now attach the docs-required ?source=html query flag; _post/_put gained params plumbing, and the create-post dry-run plan includes params for plan/request parity. Regression tests pin the param whenever an html payload is present (mocked request capture and dry-run plans) and assert its absence on mobiledoc/lexical writes. Docs: fix the jq interpolation typo in worked-recipes recipe 2 (missing backslash made the scheduled-posts line exit 5), replace the blanket "exit code 2" claim in admin-auth-and-basics with the script's actual 2-5 failure-class mapping, and state the source=html requirement in the SKILL.md --html gotcha. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
4.9 KiB
Worked Recipes: CLI and Raw API
Recipes combining the bundled scripts/ghost CLI with raw Admin API calls. Every recipe is executable end-to-end with only GHOST_URL and GHOST_ADMIN_KEY set (and jq for JSON plumbing). HTML/Lexical examples use placeholder tokens, never real credentials.
Recipe 1: Draft a post now, publish after review
Draft-first keeps half-written work off the public site while still letting you preview with admin themes.
# 1. Create the draft
ghost --json create-post --title "Release notes" \
--html "<p>What shipped this week…</p>" > /tmp/draft.json
post_id=$(jq -r '.post.id' /tmp/draft.json)
# 2. Later: re-read to fetch the CURRENT updated_at (collision guard)
ghost get-post "$post_id" | grep updated_at
# 3. Publish, passing the fresh timestamp
ghost update-post "$post_id" \
--status published \
--updated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" # NO — use the value from step 2
The last line above is deliberately wrong: never synthesize updated_at. Copy the exact string from step 2's output; the server compares timestamps literally, and a mismatch means 409.
Raw-API equivalent of step 3:
curl -sS -X PUT "$BASE/posts/$POST_ID/" \
-H "Authorization: Ghost $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept-Version: v6.0" \
--data "{\"posts\":[{\"updated_at\":\"$UPDATED_AT\",\"status\":\"published\"}]}"
Recipe 2: Review queue — everything unpublished
Drafts, scheduled, and published live on different filters; one call per status:
ghost posts --status draft --limit 50 --json | jq -r '.posts[] | "\(.title)\t\(.slug)"'
ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t\(.published_at // "-")"'
Pair it with an authoring-wide sanity check via jq types before feeding slugs onward:
ghost posts --status draft --json | jq '{count: (.posts|length), all_slugs_strings: ([.posts[].slug | type] | all(. == "string"))}'
Recipe 3: Full export, page by page
Ghost 6 caps pages at 100 rows; limit=all is gone.
page=1
while :; do
ghost posts --limit 100 --page "$page" --json > "/tmp/posts-$page.json"
next=$(jq -r '.page.next // empty' "/tmp/posts-$page.json")
jq -r '.posts[].id' "/tmp/posts-$page.json"
[ -z "$next" ] && break
page=$next
sleep 0.2
done
Note the loop reads meta.pagination.next from the CLI's .page field rather than computing (page * limit) < total; totals shift under concurrent edits.
Recipe 4: Publish at a future time (scheduling)
ghost create-post --title "Launch day" \
--html "<p>We're live.</p>" \
--status scheduled \
--published-at "2026-09-01T09:00:00.000Z"
Requirements: the timestamp must be in the future and ISO 8601; Ghost processes the queue on its own schedule (typically within five minutes of the mark). The post remains visible as scheduled in the Admin plane, invisible publicly until flip time. CLI enforces the pairing (--status scheduled without --published-at errors before any request is made).
Recipe 5: Slug-based handoff between systems
External systems key content by slug. Resolve slug → id → full record:
curl -sS "$BASE/posts/slug/$SLUG/" \
-H "Authorization: Ghost $TOKEN" \
-H "Accept-Version: v6.0" | jq -r '.posts[0].id'
Then read or edit by that id. The CLI reads by id (ghost get-post <id>); for slug lookups use the curl form above.
Recipe 6: Tag hygiene pass
Find tags nobody uses, then create missing ones for a new series:
# Usage-counted listing
ghost tags --limit 200 --json | jq -r '.tags[] | select((.count.posts // 0) == 0) | .slug'
# Create two series tags (idempotency: check existence first, creation duplicates error out)
ghost create-tag --name "Engineering" --slug engineering --description "Technical posts"
Remember relation semantics when tagging posts programmatically: PUT replaces the tag array wholesale, so send [...existing_slugs, "engineering"], not just the addition.
Recipe 7: Connectivity triage
When nothing works, descend this ladder:
# 1. Is Ghost up? (no auth required)
curl -sS "$GHOST_URL/ghost/api/admin/site/" | jq .
# 2. Does our JWT authenticate?
ghost site
# 3. Can we browse? (exercises query params + permissions)
ghost posts --limit 1
Step 1 failing = wrong URL/site down. Step 2 failing = auth contract problem (see the auth reference's signature table: expired vs invalid-algorithm vs audience). Step 3 failing while step 2 passes usually means integration permission gaps rather than token problems.
Sources
- https://docs.ghost.org/admin-api/#token-generation-examples
- https://docs.ghost.org/admin-api/posts/creating-a-post
- https://docs.ghost.org/admin-api/posts/updating-a-post
- https://docs.ghost.org/admin-api/posts/publishing-a-post
- https://docs.ghost.org/admin-api/posts/scheduling-a-post
- https://docs.ghost.org/content-api/pagination
- https://docs.ghost.org/changes