Skip to main content

Headless GraphQL API

KilnCMS exposes a curated GraphQL API (AshGraphql) for headless frontends: a world-readable delivery surface plus an authenticated write surface (create / update / workflow / soft-delete). This document is the contract for what the surface exposes and how writes are authorized.

D7 was reversed (#330). The delivery API was originally read-only by design (decision D7); mutations were added deliberately so external apps can write back into the CMS (parity with Strapi/Directus/Payload). Reads are unchanged and stay anonymous-friendly; writes require an API key and go through the exact same resource policies as /mcp — see Mutations below. A read-only key or an anonymous caller can see the mutations in the schema but cannot execute them.

Endpoints

PathAvailabilityPurpose
GET /api/graphql/schema.graphqlpublic where introspection is on; API key in productionThe running schema as SDL, for codegen. The stock build's copy is committed at docs/api/schema.graphql — see api.md
POST /gqlalways onGraphQL query endpoint (headless consumers)
/gql/playgrounddev only — not served by a production buildInteractive GraphiQL playground
/ws/gqlalways onAbsinthe websocket: subscriptions, and queries and mutations too

The endpoint is rate-limited to 60 documents a minute per client address, over /gql and /ws/gql together (the :gql bucket; see Query cost), and reads an optional bearer token (load_from_bearer). Anonymous requests are fully supported — they simply run through the resource read policies, which already make published content world-readable and everything else editor-only. So an unauthenticated query can only ever see published content and world-readable taxonomy; there is nothing to leak.

Design: delivery reads, authenticated writes

The reads are a delivery surface: anonymous-friendly, shaped around what a frontend needs (fetch published content, list it, search it), and safe by default because every read runs through the resource read policies (published content is world-readable; everything else is editor-only).

The writes (#330) are the same authoring/workflow actions the admin editor and /mcp use, exposed as mutations and gated by the identical resource policies — the schema is wider, but authorization is unchanged. See Mutations.

Queries

Content — pages & posts

Every content type (Page, Post, and any type generated by mix kiln.gen.content) gets the same delivery queries automatically, derived from its singular type name. For post:

QueryActionArgumentsReturns
postBySlug:public_by_slugslug: String!, locale: String!, fallback, fallbackLocaleone published post (or null), through the site's locale fallback chain
postTranslations:published_translationsslug: String!every published locale variant of a slug
publishedPosts:publishedlimit, offset, customFilter, customSortpublished posts, newest first, offset-paginated (PageOfPost)
searchPosts:searchquery: String!, locale, categoryId, authorId, state, tagIds, customFilterfull-text matches, relevance-ranked
semanticSearchPosts:search_semanticquery: String!, locale, customFiltervector/semantic matches
autocompletePosts:autocompleteprefix: String!, localetypo-tolerant title suggestions
searchPublishedPosts:search_publishedas searchPosts, minus statepublished-only full-text matches — the anonymous rule (published, audience: :public, unlocked) pinned server-side (#297, #1013)
semanticSearchPublishedPosts:search_semantic_publishedas semanticSearchPosts, minus statepublished-only semantic matches
autocompletePublishedPosts:autocomplete_publishedprefix: String!, localepublished-only title suggestions

Pages expose the same set: pageBySlug, pageTranslations, publishedPages, searchPages, semanticSearchPages, autocompletePages (and their *Published* twins). The published index is universal since #300 — every content type (and publishedEntries on the dynamic tier) has it, so delivery consumers never need the plain, credential-widened list.

The base search queries go through the read policy: anonymous callers match published content only, but an authenticated editor/admin actor (including a bearer API key minted on such an account) matches drafts too. Delivery consumers should use the *Published* twins, whose state filter cannot be widened by any credential — see headless-consumer-guide.md → "Delivery sites: an API key widens what you see".

*BySlug queries require both slug and locale because content is modelled per-locale (unique [slug, locale]). Use postTranslations to discover which locales exist for a slug.

A missing translation is answered along the site's fallback chain (fr-CA → fr → en, set at /editor/locales): select locale on the result to see which variant you got. fallback: false returns the requested locale or null; fallbackLocale: "fr" tries that one instead of the chain. A locale the site does not run is an error on the field, not the default locale. The top-level menu(key:, locale:, fallback:, fallbackLocale:) query follows only a configured chain. See api.md → Locale fallback.

customFilter (JSON) and customSort (String) query admin-defined custom fields: customFilter: {price: {gt: 10}}, customSort: "-price". The same operators, typing and validation as the JSON:API params — see json-api.md → "Custom fields". A reference field also takes the relationship operators has, has_all, has_none and count (customFilter: {ingredients: {has: ["<id>"], count: {gte: 2}}}), and the built-in tags and related<Type>s take has / has_all / has_none under their snake_case names: customFilter: {tags: {has_all: ["<id>", "<id>"]}, related_posts: {has_none: ["<id>"]}}. A target the caller may not read (a draft, for an anonymous caller) neither matches nor counts.

Relationships: references and referencedBy

Every content type (Page, Post, a project type, Entry) has two fields that follow :reference custom fields to the records on the other end, typed:

FieldArgumentsReturns
referencesfield: String!, limit, offset[ContentReference!]! — the targets of that field, in its order
referencedByfield, sourceType, inverse, limit, offset[ContentReferrer!]! — the records linking here, optionally only through field and only from sourceType, or through the reverse field named inverse

A reference field that names its other side (inverse_name, see extending-content.md) can be asked for by that name: referencedBy(inverse: "used_in_recipes") on an Ingredient stands for the Recipe type's ingredients field, so a client needs neither name. It is not combined with field or sourceType, and a name the record's type has no reverse field for lists nothing. The schema export lists each type's reverse fields as x-kiln-reverse-fields.

ContentReference is { position, link, target } and ContentReferrer is { field, position, link, source }. target and source are a ContentRecord: an interface that every content object implements, with id, title, slug, locale, state, published, publishedAt, insertedAt, updatedAt, customFields, and references / referencedBy themselves. Narrow with a fragment for a type's own fields. Dynamic types all share the Entry object (D17), so they are told apart by typeName:

query {
  postBySlug(slug: "pancakes", locale: "en") {
    title
    references(field: "ingredients") {
      position
      link
      target {
        __typename
        title
        slug
        ... on Post { excerpt }
        ... on Entry { typeName }
      }
    }
  }
}
  • A target or source is listed only when you could read it directly: an anonymous caller never sees a draft on either end, an editor sees them. limit and offset count only what you can see. limit defaults to, and is capped at, 100.
  • link is the data stored on the connection (a relationship's link fields), passed through as JSON; {} when the field has none.
  • A list of records resolves its references in a fixed number of reads: one for the edges and one per target type, however many records the list holds.
  • Nesting is allowed (target { references(field: …) { … } }) and is bounded by the usual limits below: both fields are priced like any to-many relationship.

Taxonomy — categories, tags & tag groups

Taxonomy is world-readable and exposed as plain (unpaginated) lists plus a slug lookup:

QueryReturns
categoriesall categories
categoryBySlug(slug: String!)one category
tagsall tags
tagBySlug(slug: String!)one tag
tagGroupsall tag groups, in picker order (position, then name)
tagGroupBySlug(slug: String!)one tag group

Each category/tag also carries pageCount / postCount aggregates and (for categories) pages / posts relationships.

A tag group is the bucket a tag is filed under — the structure Kiln's own editor uses to keep a large tag vocabulary scannable. A Tag exposes its tagGroup; a TagGroup exposes tags and a tagCount. contentTypes is a list of content-type name strings (["post"]) scoping where the group applies, and an empty list means every content type; filter on it client-side to mirror the editor's sectioning.

menu(key: String!, locale: String): Menu — one navigation menu, resolved: the GraphQL twin of GET /api/menus/:key (see navigation-menus.md). Each item's url is its target's current published path; items pointing at unpublished content, or hidden by an editor, are left out with their children. locale defaults to the site's default, and a menu with no variant in the requested locale is null rather than a fallback.

query {
  menu(key: "main", locale: "en") {
    name
    items { label url linkType openInNewTab children { label url } }
  }
}

Point in time — contentAsOf

contentAsOf(type: String!, asOf: DateTime!, limit: Int): [PointInTimeEntry!] — "what was published on this site at asOf?", reconstructed from version history: the GraphQL twin of GET /api/content/:type?as_of=. Each entry is slug, title and publishedAt, as they stood at that instant (a later rename does not leak in, and since-unpublished content is left out). limit defaults to 100 and is capped at 500. Compiled types only (page, post, an overlay's types); a dynamic type is an error. See point-in-time.md ("The collection view") for how the index is built, and GET /api/content/:type/:slug?as_of= for one document's body at that date.

query {
  contentAsOf(type: "post", asOf: "2026-03-01T00:00:00Z", limit: 20) {
    slug
    title
    publishedAt
  }
}

Health

health: String — a lightweight probe that returns "ok".

Subscriptions (real-time)

Every content type exposes a <type>Changed subscription (pageChanged, postChanged, downstream catalog types alike — plus entryChanged, one feed shared by every admin-defined dynamic type), delivered over the Absinthe websocket at /ws/gql (absinthe_phoenix protocol; pass an optional bearer token as a token connection param):

subscription {
  pageChanged {
    created { id title slug }
    updated { id title slug }
    destroyed
  }
}

Each push carries the record under created/updated (or its id under destroyed), resolved per subscriber through the same read policies as queries: an anonymous subscriber only ever receives published-visible data — draft edits simply never arrive — while a bearer-authed editor also sees draft activity. A record's publish arrives as an updated push (the workflow transition is an update).

destroyed means the record left the published feed, whichever action took it out: unpublished, archived, or deleted. A retraction is an update to Ash, and an anonymous subscriber can no longer read the record after it, so without this the one event a public reader most needs would never arrive; Kiln sends it as destroyed with the id, the one arm that needs no read (#1925). Treat it as "drop this from what you show". A bearer-authed editor receives the updated push for the same transition as well, since the record is still readable to them. Archiving or deleting a draft, which was never in the feed, stays silent for anonymous subscribers.

Under load, notification fan-out batches through an out-of-band worker (AshGraphql.Subscription.Batcher), so publishing never blocks on slow subscribers.

Mutations (writing)

Reverses D7 (#330). Writes require an API key; a read-only key or an anonymous caller is rejected by the resource policies, not by the mutation being absent. Mint keys at /editor/api-keys (admin-only); a key acts as its owning user, bounded by its access scope. This is the same auth model as /mcp — the :read_write scope is no longer MCP-only.

Every content type gets the same set of mutations, derived from its singular type name. For post (Pages, and any mix kiln.gen.content type, are identical; the dynamic tier shares one generic *_entry set):

MutationActionWhoEffect
createPost(input:):create:read_write key, editor+Creates a draft, attributed to the key's owner
updatePost(id:, input:):update:read_write key, editor+Edits content; re-fires if the record is already published
submitPostForReview(id:):submit_for_review:read_write key, editor+draft → in_review
returnPostToDraft(id:):return_to_draft:read_write key, adminin_review → draft — the return half of the approve/return pair
publishPost(id:):publish:read_write key, adminPublishes and fires artifacts
unpublishPost(id:):unpublish:read_write key, adminTakes content down, purges artifacts
deletePost(id:):destroy:read_write key, adminReversible soft-delete (AshArchival)
addPostReferences(id:, field:, targets:, position:):update:read_write key, editor+Connects records through a :reference field (below)
removePostReferences(id:, field:, targets:):update:read_write key, editor+Disconnects records from a :reference field
movePostReference(id:, field:, target:, position:):update:read_write key, editor+Reorders one connected record

Authorization mirrors /mcp exactly: a read-only key can run none of these; a :read_write key on a :viewer account can run none (the role has no authoring rights); a :read_write key on an :editor account can create/update/submit; return-to-draft, publish, unpublish and delete require an :admin account — an editor submits for review, and deciding the outcome (approve or return) is the admin's half. The hard delete (:purge) is never exposed as a mutation and is API-key-banned regardless of scope.

Media metadata — updateMediaItem

updateMediaItem(id:, input:) edits what a media item says about itself — alt, caption, decorative, focalX/focalY (0.0–1.0; moving the point re-derives the focal-aware crops) and tags (tagIds / addTagIds / removeTagIds, same rules as content). Same gate as the content writes: a :read_write key (or JWT) on an editor or admin account.

mutation ($id: ID!) {
  updateMediaItem(id: $id, input: { alt: "The kiln at dusk", focalX: 0.3 }) {
    result { id alt focalX focalY }
    errors { message }
  }
}

Uploads are not GraphQL. Files are created over REST — POST /api/media (multipart), POST /api/media/import-url, or the presigned direct-upload pair; see api.md → Uploading media. The upload route needs its own body limit, its own rate-limit bucket, and to refuse an unauthorized caller before reading a large body; /gql can offer none of those per operation.

Writing tags — replace vs merge

updatePost accepts three tag inputs (#521). tagIds is the complete set, so a partial update detaches every tag it omits; addTagIds and removeTagIds merge against the current links instead and are the right choice for any client that doesn't already hold the full list.

mutation ($id: ID!, $input: UpdatePostInput!) {
  updatePost(id: $id, input: $input) { result { id } errors { message } }
}
{ "id": "<uuid>", "input": { "addTagIds": ["<tag-uuid>"] } }

Adding an attached tag and removing an unattached one are both no-ops, so retries are safe; a repeated id in one list is de-duplicated. An unknown id is asymmetric — addTagIds fails the mutation, removeTagIds swallows it.

Sending tagIds together with either merge verb, or the same id in both addTagIds and removeTagIds, is rejected. tagIds: null counts as sending it (the field is nullable and null clears the set, so generated clients that populate every input field must omit it rather than null it); empty merge lists do not count and are simply ignored.

The merge verbs are on update* only — create* has nothing to merge against and takes tagIds alone. The related-content arrays carry the same verbs (#637): alongside relatedPostIds there are addRelatedPostIds / removeRelatedPostIds, with the same rules, and the sibling addRelatedPageIds / removeRelatedPageIds (and …RelatedEntryIds on the dynamic tier).

Writing relationships

A :reference custom field (relationships-plan.md) can be written whole through updatePost(input: { customFields: … }), but a client that only wants to connect one more record would have to read the list, edit it and send it back. Three mutations per type (and addEntryReferences and its siblings on the dynamic tier) do that edit server-side, matching Hygraph's connect / disconnect:

MutationDoes
addPostReferences(id:, field:, targets:, position:)Connects each target, as one block at position (default: the end), in the order given
removePostReferences(id:, field:, targets:)Disconnects the given ids; an id the field doesn't hold is ignored
movePostReference(id:, field:, target:, position:)Moves one connected record

Each is the record's own :update with that one field rewritten (D23), so everything updatePost does applies: the same policies (an editor scoped away from the type is refused) and :read_write key scope, expectedLockVersion (here a top-level argument, as there is no input), the optimistic lock, version history, the post.updated webhook and the re-fire on a published record, the field's own validation (min_items / max_items, the allowed target types, link fields) and the reconciled edges. A refused write returns result: null and the reasons in errors, exactly as updatePost does.

targets are { id, type, link }. type (page, post, a dynamic type's name) is needed only when the field links to more than one type. link sets the connection's link fields (R3) as a JSON-encoded object, like customFields; it is validated as the field's other values are, and a key it leaves out keeps what the connection stored.

position is one of { start: true }, { end: true }, { before: "<id>" } or { after: "<id>" }, where the id is a record already connected through the field. Giving none or more than one is an error.

Adding a record the field already holds moves it to position (merging any link values given) instead of failing as a duplicate, so a retried add…References converges on the same list. On a single-valued field add…References sets the one target (position is not consulted), remove…References clears it, and move…Reference is refused.

mutation ($id: ID!) {
  addPostReferences(
    id: $id
    field: "ingredients"
    targets: [{ id: "<page-uuid>", link: "{\"quantity\": 2}" }]
    position: { after: "<other-page-uuid>" }
    expectedLockVersion: 4
  ) {
    result { id lockVersion references(field: "ingredients") { position link target { title } } }
    errors { code message fields }
  }
}
mutation ($id: ID!) {
  movePostReference(id: $id, field: "ingredients", target: "<page-uuid>", position: { start: true }) {
    result { id }
    errors { message }
  }
}

Writing body content — the blockTree argument

The typed blocks union is not on the GraphQL surface (it can't render cleanly as a union of embedded resources, and delivery reads the fired artifacts, not the raw tree). To write body content, createPost / updatePost accept a public blockTree input: an array of block maps (the same shape the editor and MCP submit), cast into the union — which sanitizes rich-text HTML and media URLs. Omit it on an update to leave the body untouched; send [] to clear it.

mutation ($input: CreatePostInput!) {
  createPost(input: $input) {
    result { id slug state }
    errors { message }
  }
}
{
  "input": {
    "title": "Written over GraphQL",
    "slug": "hello-api",
    "blockTree": [{ "type": "rich_text", "content": "<p>Body</p>", "order": 1 }]
  }
}

Send the API key as a bearer header (same as a read token):

curl -s http://localhost:4000/gql \
  -H 'content-type: application/json' \
  -H "authorization: Bearer $KILN_API_KEY" \
  -d '{"query":"mutation($id:ID!){ publishPost(id:$id){ result{ id state } errors{ message } } }","variables":{"id":"<uuid>"}}'

Concurrency: expectedLockVersion

A mutation is last-write-wins by default: updatePost reads the record and applies your input in the same request, so writing from a copy you fetched earlier overwrites anything saved since. To make it conditional, read lockVersion (a read-only field on every content type, bumped by every content edit) and pass it back as expectedLockVersion in the input of update*, submit*ForReview, return*ToDraft, publish*, unpublish* or delete*:

mutation ($id: ID!) {
  updatePost(id: $id, input: { title: "New", expectedLockVersion: 4 }) {
    result { id lockVersion }
    errors { code message vars }
  }
}

If the record has moved on, nothing is written and the mutation returns an error with code: "precondition_failed", with the current lock_version, state and etag in vars. The check runs inside the write's transaction against the locked row. Omit the argument and nothing changes.

lockVersion does not move on a workflow transition (publishing doesn't edit content), so expectedLockVersion: 4 on publishPost means "publish the content I reviewed", not "the record is still a draft". The JSON:API ETag covers both halves (json-api.md, Concurrency).

Retrying a mutation safely

POST /gql honours Idempotency-Key like the JSON:API writes: a retry with the same key and the same request body replays the first response. See api.md → Idempotent writes.

Re-fire semantics

Firing (the immutable per-surface artifact regeneration) is bound to the :publish action, so publishPost re-fires automatically. Editing already-published content with updatePost also re-fires (the :update action carries a published-guarded re-fire, #330) — so a write-through to live content never leaves a stale artifact. Draft edits do not fire.

Query cost

Every document is checked before any of it runs, over POST/GET /gql and over /ws/gql alike. A document over a limit is answered with errors and no data:

LimitValue
Complexity200
Depth15 nested fields
Size2,000 tokens
Batch10 operations in one JSON-array body

Complexity. A field costs 1 plus the cost of the fields selected inside it. A list costs its row count times the cost of one row:

  • A paginated query (publishedPosts) counts limit rows. With no limit it counts its default page, 25.
  • A to-many relationship (tags, relatedPosts, a media item's featuredPosts, and references / referencedBy) counts limit rows if you pass one. Without limit it counts five, however many it returns. That makes a list nested inside itself (relatedPosts { relatedPosts { … } }) expensive quickly.

relatedPosts (and every related<Type>s) returns its records in the order the editor set, and a limit takes the first of that order for each parent. relatedLinks lists the same records as {id, title, slug}, in that order.

QueryCost
postBySlug below32
publishedPosts(limit: 10) below100
publishedPosts { results { title tags { name } } }25 × 7 = 175
publishedPosts { results { title tags { name } relatedPosts { title } } }25 × 12 = 300, refused
the same with limit: 1515 × 12 = 180
the same with tags(limit: 3) and relatedPosts(limit: 3)25 × 8 = 200
postBySlug { title references(field: "ingredients") { position target { title slug } } }2 + 5 × 4 = 22
the same with references(…, limit: 20)2 + 20 × 4 = 82
publishedPosts(limit: 10) { results { title references(field: "ingredients") { target { title } } } }10 × 12 = 120
references { target { references { target { references { target { … } } } } } }, unlimitedover 200, refused

If a listing page goes over, ask for a smaller page, pass limit on its relationships, or fetch the per-item detail separately.

Rate limit. Each document counts once against a budget of 60 a minute per client address. The budget is shared by /gql and /ws/gql: a document sent over the socket costs the same as a request. Over it, /gql answers 429 with a retry-after header, and the socket answers that document with an error whose extensions are {"code": "too_many_requests", "retry_after": <seconds>}. The socket stays open. A subscription counts once, when you subscribe; the updates it pushes to you are free.

Batches. A JSON array body runs each element as its own operation. Each one is counted against the rate limit, so ten operations in one request cost the same as ten requests.

Introspection (__schema, __type) is refused in production, however the document arrives. The playground needs it, which is why the playground is served in development only. __typename always works.

Deliberately not exposed

  • Hard delete. deletePost is a reversible soft-delete; the permanent :purge is never a mutation and no API key may run it.
  • Taxonomy writes. Categories and tags stay read-only over GraphQL — authored through the admin editor (or /mcp's create_tag/create_category).
  • The media library as a list. MediaItem has a GraphQL type (so it resolves as the nested featuredImage on content) but no top-level query — there is no public "list all media" endpoint. Its one mutation is the metadata edit above; uploads are REST.
  • The raw blocks tree. Blocks are a typed union not rendered over the auto API; the v2 content API surface is the fired artifacts (/api/...), not the editable tree. Render content from the fired artifacts or your own block renderer.
  • Internal fields — search_text, embedding, published_version_id, etc. are not public? and never serialized. (lockVersion is readable, but never writable; see Concurrency above.)
  • Author PII — content exposes only the opaque authorId foreign key. User has no GraphQL type (and no JSON:API resource), so there is no nested author object through which email or role could be selected. Even if the author surface were ever widened, a User field policy keeps email, role and notification preferences admin/self-only (#183). The display name is read server-side (authorize?: false) for the JSON-LD/byline only.

Playground examples

Open /gql/playground on a local development server and try these. A production deployment does not serve the playground; send the same queries as a POST to /gql instead. To try queries with nothing installed, see Try it live.

Fetch a published post by slug

query GetPost($slug: String!, $locale: String!) {
  postBySlug(slug: $slug, locale: $locale) {
    id
    title
    excerpt
    seoTitle
    seoDescription
    seoImage
    canonicalUrl
    publishedAt
    published
    wordCount
    readingTimeMinutes
    category { name slug }
    tags { name slug }
    featuredImage { alt width height }
  }
}

Variables:

{ "slug": "hello-world", "locale": "en" }

List the published blog index

publishedPosts is offset-paginated (parity with the JSON:API /published feed) — it returns a PageOfPost with results, count, and hasNextPage. limit is capped server-side (max 100, default 25), so use limit/offset to page. An omitted offset means 0, the first page. A page's cost is its size times the fields of one row, so this selection fits a page of 10; at 25 it would cost 250 and be refused.

{
  publishedPosts(limit: 10) {
    count
    hasNextPage
    results {
      id
      title
      excerpt
      publishedAt
      category { name slug }
    }
  }
}

Discover a post's translations (hreflang alternates)

query Translations($slug: String!) {
  postTranslations(slug: $slug) {
    id
    locale
    title
  }
}
{ "slug": "hello-world" }

Full-text search, faceted by category

query Search($q: String!, $categoryId: ID) {
  searchPosts(query: $q, categoryId: $categoryId) {
    id
    title
    excerpt
    highlight(query: $q, locale: "en")
  }
}
{ "q": "elixir", "categoryId": null }

⚠️ highlight is untrusted HTML — escape it before rendering. The field is a Postgres ts_headline snippet that wraps matched terms in <mark>…</mark>. The surrounding text is not HTML-escaped, so a malicious title/body could inject markup. Do not assign it to innerHTML directly. Either escape the whole string and then re-introduce <mark> yourself, or run it through a sanitizer that allows only <mark> (the server's own admin UI uses KilnCMS.Search.Highlight.to_safe_html/1, which escapes the text and keeps only <mark>). If you don't need highlighting, omit the field.

Title autocomplete (typeahead)

query Auto($p: String!) {
  autocompletePosts(prefix: $p) {
    id
    title
    slug
  }
}
{ "p": "hel" }

Browse taxonomy

{
  categories {
    name
    slug
    postCount
  }
  tags {
    name
    slug
  }
}

cURL (headless, no playground)

curl -s http://localhost:4000/gql \
  -H 'content-type: application/json' \
  -d '{
    "query": "query($slug:String!,$locale:String!){ postBySlug(slug:$slug,locale:$locale){ id title } }",
    "variables": { "slug": "hello-world", "locale": "en" }
  }'

Try it live

kilncms.dev runs Kiln, so its published content answers the same anonymous queries. Its guides are entries of a dynamic doc type, and its docs index is a page:

# The newest guides
curl -s https://kilncms.dev/gql -H 'content-type: application/json' \
  -d '{"query":"{ publishedEntries(limit: 5) { count hasNextPage results { title slug publishedAt } } }"}'

# One page by slug
curl -s https://kilncms.dev/gql -H 'content-type: application/json' \
  -d '{
    "query": "query($slug:String!,$locale:String!){ pageBySlug(slug:$slug,locale:$locale){ id title publishedAt } }",
    "variables": { "slug": "documentation", "locale": "en" }
  }'

# Published-only search
curl -s https://kilncms.dev/gql -H 'content-type: application/json' \
  -d '{"query":"query($q:String!){ searchPublishedEntries(query:$q){ title slug } }","variables":{"q":"migrations"}}'

It runs a production build, so introspection is off there (GraphQL introspection is disabled) and /api/graphql/schema.graphql answers 404 without an API key. Generate types from the committed docs/api/schema.graphql instead. The site has no categories or tags, so the taxonomy examples above return empty lists there.

Bearer tokens and drafts

Add -H 'authorization: Bearer <token>' to authenticate as an editor/admin. The bearer widens the :read and :search policies, so authenticated editors see drafts through the list/search surfaces — e.g. searchPosts/searchPages return drafts, and the JSON:API :read routes accept filter[state]=draft.

It does not widen the *BySlug queries. postBySlug / pageBySlug (and categoryBySlug / tagBySlug) run the :public_by_slug action, which hard-filters state == :published in the action itself — independent of the actor. A bearer token therefore makes no difference: a draft slug always returns null, authenticated or not.

To fetch a specific draft by slug:

  • JSON:API (bearer): GET /api/json/posts?filter[slug]=<slug>&filter[state]=draft
  • Share link: GET /preview/:token (per-draft signed token, no account needed)

See docs/api.md → "Reading drafts" for the full playbook.