Khalid / Developers
From Ghost or Strapi? Run Khalid headless.
Turn the theme off and render posts in your own front end. The Content API reads like Ghost's. The Admin API takes scoped keys, webhooks are signed, and AI agents connect over MCP.
Free and open source. Release 1.3.2.
# site.toml
mode = "headless"
# a key that can only read published content
khalid --site blog keys create --kind content \
--name "Web front end"
khalid --site blog serve
Headless mode: the theme off, everything else on.
One line in site.toml. Khalid keeps doing the work around your posts, and your front end draws the pages.
Khalid still serves
- The Content API and the Admin API
- The feeds: RSS, Atom and JSON Feed
- The sitemaps, robots.txt and llms.txt
- Redirects from old addresses
- The admin, the writing checks and webhooks
What changes
- Every HTML page answers 404, so your front end owns the pages
- The theme is never loaded, so a missing or broken theme stops nothing
- Sitemaps and feeds keep the addresses Khalid gives each post, so your front end renders at the same ones
A Content API in Ghost's query shape.
Read-only, keyed with a content key, and cacheable with ETags. If you have queried Ghost, you already know the parameters.
curl "https://example.com/api/content/v1/posts/?key=$CONTENT_KEY&filter=tag:links&limit=1&page=2"
Posts tagged links, one per page, page 2. Add formats=plaintext or order=title asc the way you would on Ghost.
{
"posts": [
{
"id": "en.why-internal-links-matter",
"slug": "why-internal-links-matter",
"title": "Why internal links matter",
"url": "https://example.com/blog/why-internal-links-matter/",
"language": "en",
"excerpt": null,
"featured": false,
"tags": ["seo", "links"],
"authors": [],
"published_at": "2026-09-14T09:00:00Z",
"updated_at": "2026-09-14T09:00:00Z",
"html": "<p>Why internal links matter.</p>"
}
],
"meta": {
"pagination": { "page": 2, "limit": 1, "pages": 2, "total": 2, "next": null, "prev": 1 }
}
}
curl "https://example.com/ghost/api/content/posts/?key=$CONTENT_KEY&filter=tag:links&limit=1&page=2"
meta.pagination. Bodies appear only in the formats you ask for.What carries over from Ghost
| On Ghost | On Khalid |
|---|---|
| Address: /ghost/api/content/posts/ | /api/content/v1/posts/ |
| Key: ?key= in the query | The same, or the header Authorization: Khalid-Content <key> |
| filter: tag:links, tag:[seo,links], tag:news+featured:true | The same grammar |
| limit and page: limit=15&page=2, or limit=all | The same |
| order: order=published_at desc | The same |
| formats: html, plaintext | html, markdown and plaintext, html by default |
| Pagination: meta.pagination with page, limit, pages, total, next, prev | The same fields |
| include and fields: related records and fewer fields | Not answered yet: the API answers 400, and every field is always sent. Tags and authors come as slugs. |
| Accept-Version: Ghost's version, such as v5.0 | v1, or leave it out. Every answer names the version served. |
Your fetch code keeps working.
Change the base address and the same code reads Khalid. It pages through the list with meta.pagination.next, which both APIs send.
Ghost's own client library asks for Ghost's address and Ghost's API version, so it does not connect to Khalid as it is. Swap it for a fetch call like this one, or for the TypeScript SDK in the Khalid repository, which is generated from the API's OpenAPI document at /api/openapi.json.
// Was: const base = "https://example.com/ghost/api/content/";
const base = "https://example.com/api/content/v1/";
// Every post that matches a filter, page by page.
export async function allPosts(key, filter) {
const posts = [];
let page = 1;
while (page) {
const url = new URL("posts/", base);
const query = { key, filter, limit: "15", page: String(page) };
url.search = new URLSearchParams(query);
const answer = await fetch(url);
if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
const json = await answer.json();
posts.push(...json.posts);
page = json.meta.pagination.next;
}
return posts;
}
An Admin API with keys that do one job.
Read and write everything at /api/admin/v1/. Each integration gets its own key with only the scopes it needs, so a leaked key does less harm.
# writes drafts only
khalid --site blog keys create --name "Publisher" --scope posts:write
# writes drafts and publishes them
khalid --site blog keys create --name "Publisher" --scope posts:write --scope posts:publish
# stops a key at once
khalid --site blog keys revoke <id>
Keys are shown once and stored hashed.
curl -X POST "https://example.com/api/admin/v1/posts/" \
-H "Authorization: Khalid $KHALID_KEY" \
-H "Content-Type: application/json" \
-d '{"posts": [{
"title": "Choosing anchor text",
"author": "asif",
"markdown": "Anchor text is the visible words of a link.\n\nRead [why internal links matter](/blog/why-internal-links-matter/) first."
}]}'
# 201 Created: {"posts": [{"id": "en.choosing-anchor-text", "status": "draft", "updated_at": "...", ...}], ...}
Add an Idempotency-Key header, and a retry makes one draft, not two. To send HTML, add ?source=html and put it in html.
curl -X PUT "https://example.com/api/admin/v1/posts/en.choosing-anchor-text/" \
-H "Authorization: Khalid $KHALID_KEY" \
-H "Content-Type: application/json" \
-d '{"posts": [{"status": "published", "updated_at": "<updated_at from the draft>"}]}'
# 200 with posts:publish. 403 without it.
# 409 when someone changed the post after you read it.
The writing checks run before a post goes live. A post they block stays a draft, and the answer lists each finding.
Scopes
- content:read
- Read published content
- posts:write
- Create and edit drafts
- posts:publish
- Publish, schedule and take down posts
- media:write
- Upload images and files
- settings:write
- Change site settings
- webhooks:manage
- Add, list and remove webhooks
- subscribers:write
- Manage newsletter subscribers
- admin:all
- Everything above
Signed webhooks for every change.
One webhook sends one event to one address. Each delivery is signed with the webhook's secret, which is shown once when you add it.
khalid --site blog webhooks add --event post.published --url https://hooks.example.com/khalid
# or over the Admin API, with a key that has webhooks:manage
curl -X POST "https://example.com/api/admin/v1/webhooks/" \
-H "Authorization: Khalid $KHALID_KEY" \
-H "Content-Type: application/json" \
-d '{"webhooks": [{"event": "post.published", "url": "https://hooks.example.com/khalid"}]}'
POST /khalid HTTP/1.1
Content-Type: application/json
Khalid-Event: post.published
Khalid-Delivery: 1
Khalid-Signature: t=1793001600,v1=<hex hmac-sha256(secret, t + "." + body)>
{
"event": "post.published",
"occurred_at": "2026-10-26T08:00:00Z",
"site": "https://example.com/blog/",
"post": { "slug": "why-internal-links-matter", "title": "Why internal links matter", ... }
}
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the Khalid-Signature header. body: the raw request body, unparsed.
export function isFromKhalid(header, body, secret) {
const fields = header.split(",").map((field) => field.trim().split("="));
const sentAt = Number(fields.find(([name]) => name === "t")?.[1]);
if (!(Math.abs(Date.now() / 1000 - sentAt) <= 300)) return false; // older than 5 minutes
const expected = Buffer.from(createHmac("sha256", secret).update(`${sentAt}.${body}`).digest("hex"));
return fields
.filter(([name]) => name === "v1")
.some(([, value]) => value.length === expected.length && timingSafeEqual(Buffer.from(value), expected));
}
/api/admin/v1/webhooks/{id}/deliveries/.Events you can send
post.published, post.published.edited, post.unpublished, post.scheduled, post.deleted, page.published, tag.added, comment.added, form.submitted, subscriber.added, lint.blocked, audit.finished, site.changed. Pages, glossary terms, case studies and changelog entries have their own published, edited and deleted events.
An MCP server for AI agents.
Each tool sends the request the HTTP API would get, so an agent obeys the same keys and the same checks as any other program.
Make a key for the agent
Give it posts:write. Add posts:publish only if the agent may publish. Leave it off and the agent writes drafts for a person to publish.
Start the server from your agent
The agent runs khalid mcp, which speaks the Model Context Protocol over stdin and stdout. The key goes in KHALID_KEY, never on the command line.
Let the writing checks decide
Every API operation is a tool, named by its operationId, plus lint_post and publish_post. A post the checks block is refused with each finding, and stays a draft.
khalid --site blog keys create --name "Agent" --scope posts:write --scope posts:publish
{
"mcpServers": {
"khalid": {
"command": "khalid",
"args": ["--site", "blog", "mcp"],
"env": { "KHALID_KEY": "<the key>" }
}
}
}
khalid agents for the full guide for agents. It ships inside khalid, so it matches the version you run.Astro and Next.js starter kits.
Each kit renders the blog from the Content API and writes the same meta tags and schema the Khalid theme writes. Both are MIT licensed, so copy what you need.
Astro
Builds static pages into dist/blog/, one folder per post, at the addresses Khalid gives them. Build again after you publish, or from a post.published webhook.
khalid --site blog keys create --kind content --name "Astro site"
# .env
KHALID_API_URL=http://127.0.0.1:2368
KHALID_CONTENT_KEY=<the key>
SITE_ADDRESS=https://www.example.com
npm install
npm run build
Next.js
Serves the blog at /blog/. Pages are made at build time and again at most every five minutes, so a new post shows without a new build.
khalid --site blog keys create --kind content --name "Next.js site"
# .env.local
KHALID_API_URL=http://127.0.0.1:2368
KHALID_CONTENT_KEY=<the key>
npm install
npm run build
npm start
Send these addresses to Khalid
- /blog/sitemap.xml, /blog/robots.txt and /blog/llms.txt
- The feeds: /blog/feed.xml, /blog/atom.xml and /blog/feed.json
- /blog/share/, the share cards, and the images inside posts
- /api/, the Content API the kit reads
- Old addresses that Khalid redirects
Built and checked in CI
Khalid's CI builds both kits against a headless demo blog whenever a change touches the kits, the API or the renderer, and checks that the home page and a post keep their title, meta tags and schema. Its tests also run each kit's khalid.js: for every post, the head the kit writes must match the head the theme writes, byte for byte.
Coming from Strapi?
Strapi gives you an admin and an API, and you build and host the front end. Khalid is built for blogs, so it can do the whole job or only the back end.
One binary
Khalid is one program. There is no Node.js app to build, no packages to update and no plugin to patch.
No separate database server
Posts are text files in the site folder, and the rest sits in a database file beside them. Back up the folder and you have the site.
A front end only if you want one
The built-in templates give you finished, fast pages with SEO and schema. Switch to headless only when you want your own front end.
Strapi fits better when you model many custom content types for apps beyond a blog. See Khalid vs Strapi, or compare Khalid with other platforms.
Questions from developers.
Can't find the answer? Email us.
Does Ghost's Content API client work with Khalid?
Not as it is. Ghost's own client library asks for Ghost's address and Ghost's API version, and Khalid answers at /api/content/v1/ with version v1. The query parameters and the pagination are the same, so code that calls the API with fetch keeps working once you change the base address.
Can Khalid run as a headless CMS?
Yes. Set mode = "headless" in site.toml. Khalid then serves the Content API, the Admin API, the feeds, the sitemaps, robots.txt, llms.txt and redirects, and leaves the pages to your own front end.
Is the content key safe to use in a browser?
A content key can only read published content, which is public anyway. You can put it in the query as ?key=, as Ghost does. The Next.js starter still keeps it on the server, which is the tidier choice.
How do I move my posts from Ghost?
Export your content from Ghost, then run khalid import ghost with the export file. Add --dry-run first to see what would come in. Khalid keeps your slugs and stores a redirect for each old address that moves.
Can an AI agent publish posts on its own?
Only if you let it. The agent's key needs the posts:publish scope to publish, and Khalid's writing checks run first. A post they block stays a draft. Leave posts:publish off and the agent can only write drafts for a person to publish.
Keep your front end.
Let Khalid run the rest.
Free and open source. One binary, with the APIs, webhooks and MCP server built in.