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
| Path | Availability | Purpose |
|---|---|---|
GET /api/graphql/schema.graphql | public where introspection is on; API key in production | The running schema as SDL, for codegen. The stock build's copy is committed at docs/api/schema.graphql — see api.md |
POST /gql | always on | GraphQL query endpoint (headless consumers) |
/gql/playground | dev only — not served by a production build | Interactive GraphiQL playground |
/ws/gql | always on | Absinthe 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:
| Query | Action | Arguments | Returns |
|---|---|---|---|
postBySlug | :public_by_slug | slug: String!, locale: String!, fallback, fallbackLocale | one published post (or null), through the site's locale fallback chain |
postTranslations | :published_translations | slug: String! | every published locale variant of a slug |
publishedPosts | :published | limit, offset, customFilter, customSort | published posts, newest first, offset-paginated (PageOfPost) |
searchPosts | :search | query: String!, locale, categoryId, authorId, state, tagIds, customFilter | full-text matches, relevance-ranked |
semanticSearchPosts | :search_semantic | query: String!, locale, customFilter | vector/semantic matches |
autocompletePosts | :autocomplete | prefix: String!, locale | typo-tolerant title suggestions |
searchPublishedPosts | :search_published | as searchPosts, minus state | published-only full-text matches — the anonymous rule (published, audience: :public, unlocked) pinned server-side (#297, #1013) |
semanticSearchPublishedPosts | :search_semantic_published | as semanticSearchPosts, minus state | published-only semantic matches |
autocompletePublishedPosts | :autocomplete_published | prefix: String!, locale | published-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".
*BySlugqueries require bothslugandlocalebecause content is modelled per-locale (unique[slug, locale]). UsepostTranslationsto 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): selectlocaleon the result to see which variant you got.fallback: falsereturns the requested locale ornull;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-levelmenu(key:, locale:, fallback:, fallbackLocale:)query follows only a configured chain. See api.md → Locale fallback.
customFilter(JSON) andcustomSort(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 operatorshas,has_all,has_noneandcount(customFilter: {ingredients: {has: ["<id>"], count: {gte: 2}}}), and the built-intagsandrelated<Type>stakehas/has_all/has_noneunder 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:
| Field | Arguments | Returns |
|---|---|---|
references | field: String!, limit, offset | [ContentReference!]! — the targets of that field, in its order |
referencedBy | field, 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.
limitandoffsetcount only what you can see.limitdefaults to, and is capped at, 100. linkis 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:
| Query | Returns |
|---|---|
categories | all categories |
categoryBySlug(slug: String!) | one category |
tags | all tags |
tagBySlug(slug: String!) | one tag |
tagGroups | all 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.
Navigation menus
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 itsaccessscope. This is the same auth model as/mcp— the:read_writescope 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):
| Mutation | Action | Who | Effect |
|---|---|---|---|
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, admin | in_review → draft — the return half of the approve/return pair |
publishPost(id:) | :publish | :read_write key, admin | Publishes and fires artifacts |
unpublishPost(id:) | :unpublish | :read_write key, admin | Takes content down, purges artifacts |
deletePost(id:) | :destroy | :read_write key, admin | Reversible 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:
| Mutation | Does |
|---|---|
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:
| Limit | Value |
|---|---|
| Complexity | 200 |
| Depth | 15 nested fields |
| Size | 2,000 tokens |
| Batch | 10 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) countslimitrows. With nolimitit counts its default page, 25. - A to-many relationship (
tags,relatedPosts, a media item'sfeaturedPosts, andreferences/referencedBy) countslimitrows if you pass one. Withoutlimitit 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.
| Query | Cost |
|---|---|
postBySlug below | 32 |
publishedPosts(limit: 10) below | 100 |
publishedPosts { results { title tags { name } } } | 25 × 7 = 175 |
publishedPosts { results { title tags { name } relatedPosts { title } } } | 25 × 12 = 300, refused |
the same with limit: 15 | 15 × 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 { … } } } } } }, unlimited | over 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.
deletePostis a reversible soft-delete; the permanent:purgeis 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'screate_tag/create_category). - The media library as a list.
MediaItemhas a GraphQL type (so it resolves as the nestedfeaturedImageon 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
blockstree. 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 notpublic?and never serialized. (lockVersionis readable, but never writable; see Concurrency above.) - Author PII — content exposes only the opaque
authorIdforeign key.Userhas no GraphQL type (and no JSON:API resource), so there is no nestedauthorobject through whichemailorrolecould be selected. Even if the author surface were ever widened, aUserfield policy keeps email, role and notification preferences admin/self-only (#183). The displaynameis 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 }⚠️
highlightis untrusted HTML — escape it before rendering. The field is a Postgrests_headlinesnippet 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 toinnerHTMLdirectly. 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 usesKilnCMS.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.