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.

Go headless
# 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.

A list wraps the posts in an array under their name and adds meta.pagination. Bodies appear only in the formats you ask for.

What carries over from Ghost

On GhostOn 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.

JavaScript
// 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.

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"}]}'
Check the signature (Node)
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));
}
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 16 hours, each time with a fresh signature. The admin keeps a delivery log, also at /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.

  1. 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.

  2. 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.

  3. 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
Run 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.

Terminal
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.

Terminal
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.