GEO — Generative Engine Optimization
Structuring published content so LLMs and answer engines (ChatGPT, Perplexity, Google AI Overviews) can discover and cite it accurately. The GEO analogue of SEO (issue #357).
Honest caveat: GEO is an unsettled discipline. These features maximize citability (structure, discoverability, clean surfaces); they do not guarantee that any given engine cites you.
Phase 1: llms.txt
Kiln serves GET /llms.txt — a Markdown index of published content following
the emerging llmstxt.org convention (the LLM analogue of
sitemap.xml). It lets answer engines find the site's canonical content in one
clean, structured file:
# KilnCMS
> Published content from KilnCMS, indexed for language models (see https://llmstxt.org).
## Pages
- [Welcome to KilnCMS](https://example.com/welcome): A world-class, Elixir-native headless CMS.
## Posts
- [Hello, World](https://example.com/blog/hello-world)Behaviour:
- Published, default-locale records only. Uses the same
authorize?: trueread andKilnCMS.CMS.ContentTypesdiscovery as the sitemap, so drafts and gated content never appear; dynamic types (D17) are included. - Grouped by content type (Pages, Posts, and any custom type), with each
entry's title, public URL, and
seo_descriptionwhen set. - Cached under one aggregate key (
KilnCMS.Cache.llms_key/1, 5-minute TTL), busted on every publish/unpublish and type change — exactly like the sitemap. - Bounded to 10,000 entries so the per-request scan stays cheap.
- Served on the rate-limited
:probepipeline alongsidesitemap.xml/robots.txt.
Implementation: KilnCMSWeb.LlmsController, route in router.ex, cache helpers
in KilnCMS.Cache (llms_key/0, bust_llms/0).
The :llm surface (Phase 2, shipped)
Every document now fires a fourth surface, :llm — a clean, chunked
Markdown rendering (KilnCMS.Firing.LlmMarkdown): the title as #, heading
blocks as real ##… headings, everything else as its plain-text projection,
blocks separated by blank lines so each is a naturally chunkable passage. A
block module may export an optional to_markdown/1 for a richer rendering.
- Delivered raw as
text/markdownatGET /api/content/:type/:slug?surface=llm(same immutable-artifact, cache-and-ETag path as the other surfaces). llms.txtlinks each entry's([md](…?surface=llm))form, per the llmstxt.org "markdown versions" convention.- Included in static export (
llm.md). - Deploy note: content published before this feature has no
:llmartifact until re-fired — run the re-fire sweep (below) for full coverage; the delivery route answers 503-retryable for a missing artifact as usual.
Expanded schema.org / JSON-LD (Phase 3, shipped)
The fired :json_ld surface goes well beyond the original bare Article:
Per-type main node (KilnCMS.Firing.SchemaOrg). Every content type
declares the schema.org @type of its document node — compiled types via the
Content macro (use KilnCMS.CMS.Content, type: :page, schema_org_type: "WebPage"), dynamic types (D17) via the schema_org_type field on the type
definition (editable at /editor/types), so a documentation type can fire a
TechArticle. Values are allowlisted (SchemaOrg.types/0: the
Article family plus the WebPage family); unknown values fall back to
Article. A LiveBlogPosting (#2046) also carries coverageStartTime
/ coverageEndTime (from its first datetime_range field) and
liveBlogUpdate: the published, public records that reference it through
its type's first reverse field, newest first, capped
(KilnCMS.Firing.LiveBlog). Pages fire WebPage, posts BlogPosting. The node also carries
the citation-relevant metadata answer engines key on: datePublished /
dateModified, inLanguage, and the SEO description; Article-family types
carry the body as articleBody, the rest as text.
Bylines (#2044). A :reference field whose definition sets
schema_org_property (:author or :contributor) adds one Person per
readable target to the main node under that property, and a ProfilePage
names its person as mainEntity — see
Author profiles and bylines.
Structured-data blocks. Four first-party blocks whose :json_ld renders
expand the @graph (and which render meaningfully on every surface):
faq— Q&A rows, fired as aFAQPagenode (Question/acceptedAnswer). On:llmeach question becomes a###heading with its answer as the following passage.how_to— ordered steps, fired as aHowTonode with positionedHowToSteps; a numbered list on:llm.claim— one sourced statement (citation / source metadata on claims, the per-claim counterpart of provenance #340): fired as a schema.orgClaimnode carrying itscitation, or aClaimReview(withreviewRating) when a fact-check rating is set. The citation rides the:websurface as a<cite>link and the:llmsurface as a trailingSource: [title](url)line, so extracting engines pick up the source wherever they read.gallery— an ordered set of images, fired as oneImageGallerynode holding anImageObjectper image with its alt asnameand its caption ascaption(#482). One node for the collection, not N loose images: that is the difference between "this page has several pictures" and "this page is a gallery", and it is why the block exists rather than a row ofimageblocks.
And one block that deliberately fires nothing. The accordion renders
the same <details>/<summary> panels as faq and contributes no node.
That is not an oversight, it is the reason it was added. faq always fires
FAQPage, so an editor reaching for "a thing that collapses" — a specification
table, a changelog, a set of terms — was publishing a claim that the page is a
list of questions and answers, which answer engines act on. The split is by
meaning, not by looks: use faq for genuine questions and answers, accordion
for everything else that folds.
Same deploy note as the :llm surface: content published before this feature
carries the old JSON-LD until re-fired.
Re-fire sweep
KilnCMS.Firing.Sweep.run/0 enqueues one FireWorker per published Page /
Post / dynamic Entry (all locales, all orgs), refreshing every fired surface
through the normal :firing Oban queue — bounded concurrency, retries,
reference invalidation and search re-indexing included; editorial state,
versions and timestamps untouched. Run it once after any deploy that changes
what firing produces:
# dev / checkout
mix kiln.refire_all
# prod release — against the running node (Coolify → app container terminal)
/app/bin/kiln_cms rpc "KilnCMS.Firing.Sweep.run()"
# watch it drain (0 = done)
/app/bin/kiln_cms rpc "KilnCMS.Firing.Sweep.pending() |> IO.inspect()"