Skip to content

Headless & APIs ​

Introduction ​

A kitloom site does not need its themes to be read. Three doors lead to the same content:

DoorAddressFor
Page JSONany site address, with Accept: application/jsona decoupled front end, a mobile app, a theme's client-side navigation
REST API/wp-json/ and the namespaces under itWordPress clients, apps and scripts that read and write records
MCP server/mcpAI agents — Claude, Cursor and other MCP clients

Page JSON is part of kitloom/site. The REST root is too; what answers under it comes from modules — kitloom/api brings WordPress's wp/v2. The MCP server is kitloom/ai.

Every page as JSON ​

Ask any site address for JSON and you get the page the theme would have drawn, as data:

bash
curl -H 'Accept: application/json' https://example.com/books/dune/
json
{
    "kind": "single",
    "template": "single-book",
    "url": "https://example.com/books/dune/",
    "content": {
        "kind": "single",
        "id": 42,
        "type": "book",
        "slug": "dune",
        "title": "Dune",
        "link": "https://example.com/books/dune/",
        "content": "<p>…</p>",
        "excerpt": "…",
        "date": "…",
        "author": { "id": 1, "name": "Jane", "slug": "jane", "link": "https://example.com/author/jane/" },
        "terms": { "genre": [{ "id": 7, "name": "Science fiction", "slug": "science-fiction", "link": "…" }] }
    },
    "breadcrumbs": [],
    "widgets": {},
    "site": { "name": "My Library", "description": "…", "url": "https://example.com/", "language": "en" }
}
  • kind is what the main query found: single, page, home, category, tag, taxonomy, author, date, post-type-archive, search or not-found.
  • template is the template the active theme would render it with, by the template hierarchy; null when the site has no theme.
  • url is the page's canonical address.
  • The rest are the page's parts — the site's own (content, posts, breadcrumbs, widgets, site) and those of installed modules (menus and menuLocations from navigation, head and schema from SEO, shortcodes, the shop's cart, catalog, product…). The content text has been through the content filters: blocks and shortcodes are drawn. menus carries the menus in the site's locations, the ones menuLocations names, not every menu the database holds. Every menu location and widget zone added in the admin is in menuLocations and widgets too — see Menus & Widgets.

Choosing the parts ​

Without include a page has its default set: the parts each provider marks for JSON (the posts only on lists, content not on a 404). Name the parts to get only those:

bash
curl -H 'Accept: application/json' 'https://example.com/about/?include=content,menus'

A part is worked out only when it is asked for, so a narrow include is also a cheaper request.

Lists and pagination ​

An archive, the blog page and the search are lists. Their posts are in the posts part — the entries and the pagination:

bash
curl -H 'Accept: application/json' https://example.com/category/news/page/2/
json
{
    "kind": "category",
    "posts": {
        "items": [{ "id": 12, "type": "post", "slug": "hello-world", "title": "Hello world", "link": "…", "excerpt": "…", "thumbnail": null, "terms": {} }],
        "pagination": { "page": 2, "pages": 5, "perPage": 10, "total": 47, "next": "…/page/3/", "previous": "…/category/news/", "links": [] }
    }
}

There is no separate list endpoint: the next page of a list is read from the page itself, GET <archive>/page/2/. A "load more" button, an infinite scroll or a front end's router all use the site's own addresses.

Not found and redirects ​

A page not found answers 404, as the HTML page of a theme does, with its document all the same — "kind": "not-found" and the parts (menus, breadcrumbs): draw your own not-found page from it and pass the status on. A list or a search with nothing in it is still a page and answers 200. If your HTTP client throws on a 4xx (axios, ofetch), read the body from the error.

A record reached by a wrong address still gets a 301 to its canonical address — follow redirects in your client. A missing trailing slash alone is not redirected for a JSON client.

The schema of a page ​

The document is typed: every part of kitloom/site and of the bundled modules declares a JSON Schema of what it gives, and the site writes the schema of the page as it answers it — its installed parts, the active theme's included:

bash
php artisan kitloom:schema                                   # schema/page.json, schema/parts/<key>.json
php artisan kitloom:schema --types=resources/js/kitloom.d.ts # and TypeScript declarations

page.json describes kind, template, url and a property per part under its key; the definitions the parts share — Entry, Image, Reference, MenuItem, Comment — are gathered under $defs. A client validates an answer against page.json, and a Vue theme imports the declarations — see Writing a Theme → Vue.

A part of your own declares its schema too: schema() is part of the Kitloom\Site\Page\Part contract, written with Kitloom\Site\Page\Schema\Shape; the site README has the shapes.

Caching ​

CachePage keeps pages for guests while SITE_CACHE_TTL is set, the JSON form separately from the HTML one, until a content table is saved. Signed-in users are never served from the cache.

Running without a theme ​

With no active theme every page answers with JSON, whatever the client asks — a headless site. That is the case when:

  • Appearance → Themes → Deactivate was used, or php artisan site:theme --none (the installer's answer when no theme is kept): the kitloom_theme option is an empty string;
  • or no theme is chosen and APP_THEME is empty.

The admin, the REST API and the MCP server work as usual. Activate on any theme's card brings the rendered site back.

The same JSON is available in PHP without a request — for a static export, a test, a queued job:

php
use Kitloom\Site\Site;

$page = app(Site::class)->render('/books/dune/', Site::JSON);

$page->kind;        // PageKind::Single
$page->toArray();   // the document above

See Architecture.

The REST root ​

/wp-json/ belongs to the site, as it does in WordPress, and is there whether or not any API module is installed:

bash
curl https://example.com/wp-json/

answers with WordPress's index: the site's name, description, url, timezone_string, the namespaces and their routes. Anything else under the prefix that no namespace answers is WordPress's "no route":

json
{ "code": "rest_no_route", "message": "…", "data": { "status": 404 } }

Errors thrown by any route under the prefix reach the client in the same shape. The routes have no session and no CSRF unless a namespace puts the web group on its own routes.

What answers under the root comes from modules, each in a namespace of its own, and none needs another:

NamespacePackageWhat
wp/v2kitloom/apiWordPress's REST API: posts, pages, any shown type, terms, media, the current user
contact-form-7/v1kitloom/formsSending a contact form: POST /wp-json/contact-form-7/v1/contact-forms/{id}/feedback
wc/store/v1kitloom/shopThe cart and checkout, with the session (web group)

A module adds its own with a Kitloom\Site\Contracts\RestNamespace tagged RestNamespace::TAG — see Extending. app(Kitloom\Site\Rest\RestApi::class)->url('reviews/v1', '5/votes') builds an address under the root (or take RestApi in a constructor).

Cross-origin requests ​

The REST prefix joins Laravel's cors.paths, so a front end on another origin may call /wp-json/ (kitloom-site.rest.cors). Page addresses are not added: a front end on another origin that reads page JSON needs those paths in the application's config/cors.php.

The WordPress REST API ​

kitloom/api serves /wp-json/wp/v2/ with WordPress's JSON for the core fields, so WordPress clients work for the basics:

RouteMethodsWhat
/wp/v2/types, /wp/v2/taxonomiesGETThe post types and taxonomies the API shows
/wp/v2/posts, /wp/v2/pages, /wp/v2/{rest base}GET, POSTA list, a new record
/wp/v2/{rest base}/{id}GET, POST/PUT/PATCH, DELETEOne record; DELETE trashes, force=true deletes
/wp/v2/categories, /wp/v2/tags, /wp/v2/{terms base}GET, POSTTerms; one term at /{id}
/wp/v2/media, /wp/v2/media/{id}GETThe media library, with media_details.sizes
/wp/v2/users/meGETThe user of the application password

Lists take WordPress's parameters — page, per_page (10, at most 100), search, slug, include/exclude, order, orderby, a taxonomy's base to filter by terms, and more — and answer with X-WP-Total, X-WP-TotalPages and Link. _fields narrows an answer; context=edit adds raw values for those who may edit. content.rendered has been through the content filters, as on the record's page.

Application passwords ​

A request without a password is a visitor and sees what is published. An application password makes it its user's request: the user sees and writes what their role allows, and writes go through the same writers the admin uses. A user makes passwords on REST API → Application Passwords; one is read-only or reads and writes, and may expire. It is shown once, as {id}|{secret}, and sent as a Bearer token or, the way WordPress clients send application passwords, as Basic auth with the username or email:

bash
curl -H 'Authorization: Bearer 12|…' https://example.com/wp-json/wp/v2/users/me
curl -u 'admin:12|…' https://example.com/wp-json/wp/v2/posts?status=draft

A password never signs in to the site. Web servers that drop the Authorization header (Apache with CGI/FPM) must pass it on, as for WordPress.

REST API → Settings turns wp/v2 off, chooses the post types and taxonomies it shows and sets the rate limit (120 requests a minute per user or address). Other modules' namespaces are not affected. See the package's How it works page for the full list of parameters.

AI agents (MCP) ​

kitloom/ai is an MCP server at /mcp — the Model Context Protocol that Claude, Cursor and other agents speak. Its tools read and write the site's posts, pages, terms, media and settings, tell what lives at an address and show a page as the site renders it. The agent works with the rights of the user whose key it holds. The whole story — every client, every tool, what agents may not touch — is on AI Agents.

Turning it on ​

  1. AI agents → Settings: turn on AI agents. Until then /mcp answers 404.
  2. The permission Let AI agents work with my application passwords (use_ai_agents): administrators have it; give it to another role only if that role should have agents.
  3. AI agents → Connect an agent makes an application password and shows it once, with the settings to paste into the agent. Any application password of the REST API works the same way; a read-only one gets the reading tools only.

Connecting an agent ​

The key goes in the Authorization header:

bash
claude mcp add --transport http kitloom https://example.com/mcp --header "Authorization: Bearer 12|…"

claude.ai's custom connectors send it too (No sign-in, then a request header). A client that cannot send headers at all takes an address with the key in it, https://example.com/mcp/k/12.…, which Connect an agent also shows. That address is a password: give it a key of its own, with an expiry. kitloom-ai.mcp.secret_address turns such addresses off.

For an agent on the machine the site runs on, php artisan mcp:start kitloom serves the same tools over stdio, as the user KITLOOM_AI_USER names:

bash
claude mcp add kitloom -e KITLOOM_AI_USER=admin -- php artisan mcp:start kitloom

What the agent can do ​

describe_site comes first: the post types and taxonomies, the statuses, and what the key may do with each. Then list_content, get_content, create_content (a draft unless the agent gives a status), update_content, trash_content and restore_content; list_terms, create_term, update_term, trash_term, restore_term; list_media, upload_media, update_media; list_users; list_settings, update_setting; resolve_url (what lives at an address and which templates the theme tries) render_page (the page as text, HTML or its parts as JSON) and get_page_schema (the shape of those parts).

Installed modules add their own tools, shown only to agents whose user has the module's permission — menus, head meta, redirects and the 404 log, custom fields, forms and their messages, the shop's products and orders, sliders, the activity log, Site Health. A module offers a tool with a Kitloom\WpSchema\Contracts\AgentTool tagged AgentTool::TAG and turns its tools off with kitloom-<name>.ai.register.

An agent sees what the key's user would see: every record of a type whose others' posts that user may edit, otherwise what is published and the user's own records. It reads only declared post types and attachments, and a password-protected text only if the user may edit the record. It writes as the key's user through the same writers — so the activity log shows its changes under that user, and whatever the user may not do is refused. Nothing is deleted for good. Settings that decide who gets in, which doors are open and what code runs on every page are locked: an agent sees them, but a person changes them in the admin.

See the kitloom/ai package's How it works page for every tool and setting.

kitloom — modules and themes for Laravel.
Pricing · Services · License · Refunds · Privacy · Community · [email protected]