Flowcourier.Umbraco.AEO
17.5.0
dotnet add package Flowcourier.Umbraco.AEO --version 17.5.0
NuGet\Install-Package Flowcourier.Umbraco.AEO -Version 17.5.0
<PackageReference Include="Flowcourier.Umbraco.AEO" Version="17.5.0" />
<PackageVersion Include="Flowcourier.Umbraco.AEO" Version="17.5.0" />
<PackageReference Include="Flowcourier.Umbraco.AEO" />
paket add Flowcourier.Umbraco.AEO --version 17.5.0
#r "nuget: Flowcourier.Umbraco.AEO, 17.5.0"
#:package Flowcourier.Umbraco.AEO@17.5.0
#addin nuget:?package=Flowcourier.Umbraco.AEO&version=17.5.0
#tool nuget:?package=Flowcourier.Umbraco.AEO&version=17.5.0
Flowcourier.Umbraco.AEO
Answer Engine Optimization (AEO) for Umbraco. Make your Umbraco site readable by LLMs and AI answer engines (ChatGPT, Claude, Perplexity, Gemini, …) by serving your published content in the formats those tools understand — generated live from the content cache, with no editor work and no content changes required.
Install the package, and your site immediately gains three endpoints:
| Endpoint | What it returns |
|---|---|
/llms.txt |
A curated index of your site — the llms.txt standard: title, summary, and a grouped list of links (one per page) pointing at each page's Markdown version. |
/llms-full.txt |
The entire site rendered to Markdown in a single document — every indexable page, concatenated with source-URL markers. Ideal for feeding a whole site into a model's context or a RAG pipeline. |
/{any-page-path}.md |
Any page as Markdown. Append .md to any URL (e.g. /products/widget.md) and get a clean Markdown rendering of that page instead of HTML. |
Everything is derived from the live published-content cache, so the output always reflects what's currently published. Results are cached in memory for a short, configurable window — and refreshed immediately on publish.
The endpoints are also discoverable and crawler-friendly out of the box:
- Every HTML content page advertises its Markdown version via a
Link: <…/page.md>; rel="alternate"; type="text/markdown"response header and a<link rel="alternate" type="text/markdown">tag in<head>; Markdown responses point back withrel="canonical". - Requesting a normal page URL with
Accept: text/markdownreturns the Markdown representation directly (content negotiation, withVary: Accept). - All AEO responses carry
ETag/Last-Modifiedvalidators and answerIf-None-Match/If-Modified-Sincewith304 Not Modified, and supportHEAD.
Beyond the Markdown surface, the package also ships two more zero-config features:
- Structured data (JSON-LD) — WebSite,
WebPage/Article, breadcrumb and FAQ markup generated from your content and
auto-injected into every rendered page's
<head>; plus Organization identity markup that editors fill in on a backoffice "Structured Data" dashboard (never guessed from site content). - AI crawler analytics — visits from GPTBot, ClaudeBot, PerplexityBot and 20+ other AI crawlers are recognised and counted, shown on the AEO Dashboard under Settings → Flowcourier. Privacy-safe by design: only the bot, path and response are stored — never IP addresses or raw user-agent strings.
Why AEO?
Search engines read HTML; answer engines prefer Markdown. llms.txt and
llms-full.txt are emerging conventions that let AI crawlers discover and ingest
your content efficiently — without wading through navigation, scripts, styling and
tracking markup. This package produces all three artefacts automatically from your
existing Umbraco content.
Installation
dotnet add package Flowcourier.Umbraco.AEO
That's it. The package self-registers through an Umbraco pipeline filter — no
changes to Program.cs, no composer of your own, no route configuration. After the
next build/run:
https://your-site/llms.txthttps://your-site/llms-full.txthttps://your-site/about-us.md(or any page URL +.md)
are all live.
Requirements: Umbraco 17.5 or later (net10.0), rendering its own pages (MVC/Razor).
Works on single-site and multi-site installs (output is scoped to the site root
that matches the request's hostname via your Umbraco domain configuration).
Not for headless setups. Discovery headers, JSON-LD injection and crawler analytics all happen where the page is rendered. On a decoupled front end (e.g. the Content Delivery API with a separate front-end host), those features never reach your public site, and the AEO endpoints would sit on the CMS host — where crawlers never look. If Umbraco doesn't render your front end, this package is not a fit.
Multilingual sites
On culture-variant sites the output is generated per language, following the same
domain configuration Umbraco uses to route your pages — both host-based
(es.your-site/llms.txt) and path-prefix (your-site/es/llms.txt) setups:
https://your-site/llms.txt→ default culturehttps://your-site/es/llms.txt→ Spanish (and/es/llms-full.txt,/es/about-us.md)
Titles, summaries, body content and the generated .md links all resolve in that
culture, and a page is only listed for a language when it is actually published in it —
so each language's llms.txt matches what Umbraco itself serves. Each culture is cached
independently.
Culture-prefixed
llms.txt/llms-full.txtURLs support a single path segment (/es/,/de-at/). Host-based domains have no such limit..mdpages work at any depth. (Multi-segment domain prefixes for the two text endpoints aren't routed.)
Discoverability: the package can advertise the AEO files in
robots.txtfor you — see robots.txt hint below. It's opt-in.
What gets included?
A page appears in /llms.txt and /llms-full.txt when it is:
- Routable — it has a real, resolvable URL (element/folder nodes without a template are skipped automatically).
- Not no-indexed — none of the configured no-index properties (default
noIndex,hideFromSearch) are ticked. - Not excluded from listings — none of the configured exclude properties
(default
hideFromSitemap,excludeFromSitemap,umbracoNaviHide) are ticked. - Not an excluded document type — its doc-type alias isn't in the exclude list.
Pages whose doc-type is in OptionalDocumentTypeAliases (default: error, search,
login, thank-you pages) are grouped under a ## Optional heading in llms.txt so
small-context models can safely skip them.
Individual .md requests serve any published page directly, but return 404 for
no-indexed pages and excluded document types.
How a page becomes Markdown
The converter walks each page's properties generically:
- Rich text / grid HTML → Markdown (via ReverseMarkdown), after stripping scripts, iframes, style blocks, icon fonts and inline SVG.
- Block List & Block Grid content → flattened recursively so nested blocks are included.
- Media pickers → Markdown image links (video files are skipped). Relative URLs are made absolute (configurable).
- Text / multi-value properties → paragraphs / bullet lists.
- Layout, theme, SEO and system properties → skipped, so they don't pollute the
output (see
SkippedPropertyAliases).
The page title and description are resolved by trying a configurable list of property aliases (falling back to the node name), so it works with any content model without ModelsBuilder types.
Configuration
All settings are optional — the defaults work out of the box. Configure under the
Flowcourier:Aeo section of appsettings.json:
{
"Flowcourier": {
"Aeo": {
"Enabled": true,
// Feature toggles
"EnableLlmsTxt": true,
"EnableLlmsFullTxt": true,
"EnableMarkdownPages": true,
// Route customization
"LlmsTxtPath": "/llms.txt",
"LlmsFullTxtPath": "/llms-full.txt",
"MarkdownSuffix": ".md",
// Discovery & HTTP behaviour
"EnableAlternateLinkHeaders": true,
"EnableAlternateHeadLink": true,
"EnableContentNegotiation": true,
"EnableConditionalRequests": true,
"InvalidateCacheOnPublish": true,
// robots.txt hint (opt-in)
"Robots": {
"Enabled": false,
"Directive": "Llms",
"IncludeFullTxt": true
},
// Structured data (JSON-LD) injected into <head>
"Schema": {
"Enabled": true,
"EnableOrganization": true,
"OrganizationOnAllPages": false,
"EnableWebSite": true,
"EnableWebPage": true,
"EnableBreadcrumbs": true,
"EnableFaq": true,
"CacheSeconds": 300,
"Organization": {
"Name": null, "LegalName": null, "Url": null, "LogoUrl": null,
"SameAs": [], "Email": null, "Telephone": null
},
"TypeMappings": { "blogpost": "Article", "newsItem": "NewsArticle" },
"DatePublishedPropertyAliases": [ "publishDate", "articleDate", "postDate", "date" ],
"AuthorPropertyAliases": [ "author", "authorName", "writer" ],
"Faq": {
"DocumentTypes": [],
"QuestionPropertyAliases": [ "question", "faqQuestion" ],
"AnswerPropertyAliases": [ "answer", "faqAnswer" ]
}
},
// AI crawler analytics (the "AI Crawlers" backoffice dashboard)
"CrawlerAnalytics": {
"Enabled": true,
"RetentionDays": 90,
"AdditionalBots": [],
"DisabledBots": []
},
// Behaviour
"AbsoluteUrls": true,
"LlmsTxtCacheSeconds": 60,
"LlmsFullCacheSeconds": 600,
// Content model mapping (which property aliases mean what)
"TitlePropertyAliases": [ "seoTitle", "metaTitle", "pageTitle", "browserTitle", "title" ],
"DescriptionPropertyAliases": [ "seoMetaDescription", "metaDescription", "description", "summary", "abstract" ],
"NoIndexPropertyAliases": [ "noIndex", "hideFromSearch" ],
"ExcludeFromListingPropertyAliases": [ "hideFromSitemap", "excludeFromSitemap", "umbracoNaviHide" ],
// Document-type filtering
"ExcludedDocumentTypeAliases": [],
"OptionalDocumentTypeAliases": [ "errorPage", "error404", "searchPage", "loginPage", "thankYouPage" ],
// Extra property aliases to keep out of Markdown bodies
// (added on top of a built-in baseline of common system/SEO/layout aliases)
"SkippedPropertyAliases": []
}
}
}
Settings reference
| Setting | Default | Description |
|---|---|---|
Enabled |
true |
Master switch. When false, all endpoints and the .md handler become no-ops. |
EnableLlmsTxt |
true |
Serve /llms.txt. |
EnableLlmsFullTxt |
true |
Serve /llms-full.txt. |
EnableMarkdownPages |
true |
Serve pages as Markdown on the .md suffix. |
LlmsTxtPath |
/llms.txt |
Route for the index. |
LlmsFullTxtPath |
/llms-full.txt |
Route for the full dump. |
MarkdownSuffix |
.md |
Suffix that triggers Markdown rendering. |
EnableAlternateLinkHeaders |
true |
Emit Link: <….md>; rel="alternate"; type="text/markdown" on HTML content pages, and rel="canonical" / rel="alternate"; type="text/html" back-pointers on Markdown responses. |
EnableAlternateHeadLink |
true |
Inject <link rel="alternate" type="text/markdown"> into <head> of rendered pages (no-op on hosts without the default MVC tag helpers — the Link header still provides discovery). |
EnableContentNegotiation |
true |
Serve Markdown from the canonical page URL when the Accept header explicitly prefers text/markdown; emits Vary: Accept on both representations. |
EnableConditionalRequests |
true |
Emit ETag/Last-Modified and answer If-None-Match/If-Modified-Since with 304. |
InvalidateCacheOnPublish |
true |
Clear the cached llms.txt/llms-full.txt whenever the published-content cache changes (TTLs stay as a safety net). |
Robots.Enabled |
false |
Serve /robots.txt with an AEO discovery line (see below). |
Robots.Directive |
Llms |
Keyword used for the hint line. |
Robots.IncludeFullTxt |
true |
Also emit a {Directive}-full: line for /llms-full.txt. |
Schema.Enabled |
true |
Inject a JSON-LD graph into the <head> of rendered pages (see Structured data). |
Schema.EnableOrganization |
true |
Allow the Organization node (homepage only unless OrganizationOnAllPages). It only actually emits once a name is provided — on the Structured Data dashboard or in Schema.Organization. |
Schema.OrganizationOnAllPages |
false |
Repeat the Organization node on every page instead of just the homepage. |
Schema.EnableWebSite |
true |
Emit a WebSite node on the homepage. |
Schema.EnableWebPage |
true |
Emit a WebPage (or mapped Article/…) node on every eligible page. |
Schema.EnableBreadcrumbs |
true |
Emit a BreadcrumbList on pages below the homepage. |
Schema.EnableFaq |
true |
Emit an FAQPage node when question/answer pairs are found. |
Schema.CacheSeconds |
300 |
Cache lifetime for the serialized graph per (page, culture); publishes clear it immediately. |
Schema.Organization.* |
unset | Organization identity in appsettings (name, legal name, URL, logo, sameAs profiles, email, phone). The backoffice Structured Data dashboard overrides these field by field; no name in either place = no Organization markup. |
Schema.TypeMappings |
blog/news → Article | Doc-type alias → schema.org type for the per-page node; unmapped types emit WebPage. |
Schema.DatePublishedPropertyAliases |
publishDate, … |
Aliases checked in order for datePublished; falls back to the node's create date. |
Schema.AuthorPropertyAliases |
author, … |
Aliases checked in order for the author (string values and content pickers both resolve). |
Schema.Faq.* |
question/answer |
Doc-type filter (empty = all pages) and the question/answer aliases scanned on the page and its Block List/Grid elements. |
CrawlerAnalytics.Enabled |
true |
Record AI-crawler visits and serve the "AI Crawlers" dashboard data. |
CrawlerAnalytics.RetentionDays |
90 |
Days of hits to keep (pruned daily); 0 keeps everything. |
CrawlerAnalytics.FlushIntervalSeconds |
15 |
How often the background writer flushes queued hits (one batched transaction per flush). |
CrawlerAnalytics.MaxBatchSize |
200 |
Maximum hits written per flush transaction. |
CrawlerAnalytics.QueueCapacity |
5000 |
In-memory queue size between requests and the writer; overflow is dropped, never blocking a request. |
CrawlerAnalytics.AdditionalBots |
[] |
Extra crawlers to track: { Id, Name, Vendor, Category, UserAgentToken }. |
CrawlerAnalytics.DisabledBots |
[] |
Ids of built-in crawlers to stop tracking (e.g. bytespider). |
AbsoluteUrls |
true |
Rewrite relative media/link URLs to absolute using the request scheme + host. |
LlmsTxtCacheSeconds |
60 |
In-memory cache lifetime for the index (per site root). |
LlmsFullCacheSeconds |
600 |
In-memory cache lifetime for the full dump (per site root). |
TitlePropertyAliases |
see above | Property aliases checked in order for a page title; falls back to the node name. |
DescriptionPropertyAliases |
see above | Property aliases checked in order for a page description/summary. |
NoIndexPropertyAliases |
noIndex, hideFromSearch |
Boolean aliases that mark a page as no-index (excluded everywhere; .md returns 404). |
ExcludeFromListingPropertyAliases |
hideFromSitemap, excludeFromSitemap, umbracoNaviHide |
Boolean aliases that exclude a page from the listings (still reachable individually as .md). |
ExcludedDocumentTypeAliases |
[] |
Doc-type aliases to exclude entirely from AEO. |
OptionalDocumentTypeAliases |
error/search/login/thank-you | Doc-type aliases grouped under ## Optional in llms.txt. |
SkippedPropertyAliases |
[] |
Extra property aliases to keep out of Markdown bodies, added to the built-in baseline. |
robots.txt hint
Set Robots.Enabled to true and the package serves /robots.txt with discovery
lines for the AEO files:
Llms: https://your-site/llms.txt
Llms-full: https://your-site/llms-full.txt
User-agent: *
Allow: /
- If you already have a physical
wwwroot/robots.txt, its content is preserved — the hint lines are prepended and any previous copies of them are de-duplicated. - If you don't, the minimal permissive block above is generated.
- The middleware runs before the static-file handler, so it wins over a physical
robots.txtwhile keeping that file's rules intact.
Llms:is not an official robots.txt directive (crawlers ignore unknown lines), so it's a discovery pointer, not a crawl rule. Change the keyword withRobots.Directiveif you prefer another convention.
Structured data (JSON-LD)
Every eligible rendered page gets one <script type="application/ld+json"> in its
<head> with a schema.org graph. Page-level markup is generated from your content —
no templates to edit; the organization identity is the one thing you fill in
yourself (a two-minute form in the backoffice):
Organization— your company identity, and the one node that is never guessed: it only emits once a name has been provided. Editors fill it in under Settings → Flowcourier → AEO Dashboard → Structured Data in the backoffice (name, legal name, URL, logo, profile links, contact details — stored in the database); developers can also set the same fields viaSchema:Organizationin appsettings, with backoffice values winning field by field. Emitted on the homepage by default (schema.org guidance: one representative page is enough); setOrganizationOnAllPagesif you want it everywhere. Fill inSameAs,LogoUrlandLegalNamefor real deployments — verified identity is a core AI trust signal.WebSite— homepage only: site name + URL.WebPage/Article/NewsArticle/ … — every page, typed viaTypeMappings(doc-type alias → schema type; blog/news doc-types map to articles out of the box). Includes title, description, URL, language,datePublished(from your date property, falling back to the create date),dateModifiedand the author (string fields and content pickers both resolve).BreadcrumbList— the page's ancestor trail, on every page below the homepage.FAQPage— when the page (or any of its Block List / Block Grid elements) has a filled question and answer property (question/answerby default — configurable), the pairs are emitted as FAQ markup. Rich-text answers are stripped to plain text.
Eligibility follows the same rules as the Markdown alternate: preview renders, non-200 responses, no-indexed pages and excluded document types never emit schema. The graph is cached per page + culture and refreshed on publish. Validate your output at validator.schema.org or Google's Rich Results test.
AI crawler analytics
The package recognises the user agents of 20+ AI crawlers — OpenAI (GPTBot, ChatGPT-User, OAI-SearchBot), Anthropic (ClaudeBot, Claude-User, Claude-SearchBot), Perplexity, Google-Extended, Meta, Apple, Common Crawl, Bytespider and more — and records every visit: which bot, which path, response status, and whether it was served HTML, Markdown, llms.txt or robots.txt.
Open Settings → Flowcourier → AEO Dashboard in the backoffice: hits per crawler (with operator and purpose — training, AI search, or live assistant fetches), HTML vs Markdown ratio (is your AEO surface actually being used?), top pages, and a recent-visits log. 7-day and 30-day views. The dashboard's children hold the Structured Data form and the read-only settings page.
Built for production safety:
- Never slows a request down — matching is a cheap in-memory scan; hits go into a bounded queue and a background writer persists them in batches (one short transaction per flush, SQLite-friendly). Under pressure, hits are dropped rather than queued unboundedly.
- GDPR-safe by design — no IP addresses, no raw user-agent strings, no query strings are ever stored, so no personal data is processed.
- Bounded storage — a daily job prunes hits older than
RetentionDays(default 90); theFcAeoCrawlerHittable is created automatically on startup. - Track additional bots (or mute built-ins) via
AdditionalBots/DisabledBots, and turn the whole feature off withCrawlerAnalytics:Enabled = false.
Markdown discovery & content negotiation
Every rendered HTML content page is self-describing:
GET /products/widget
→ Link: <https://your-site/products/widget.md>; rel="alternate"; type="text/markdown"
→ Vary: Accept
→ …<head> contains <link rel="alternate" type="text/markdown" href="/products/widget.md">…
and the Markdown side points back:
GET /products/widget.md
→ Link: <https://your-site/products/widget>; rel="canonical"
→ Link: <https://your-site/products/widget>; rel="alternate"; type="text/html"
A client can also skip the .md suffix entirely and negotiate:
GET /products/widget
Accept: text/markdown
→ 200 text/markdown, Vary: Accept, Content-Location: /products/widget.md
Negotiation is deliberately conservative: only an explicit text/markdown token
that outranks HTML triggers it — browser Accept headers (text/html,…,*/*;q=0.8)
can never be hijacked, because wildcards don't count toward Markdown. Pages that are
no-indexed or on an excluded document type never advertise and never negotiate.
CDN note: negotiation adds
Vary: Acceptto HTML content pages so shared caches keep the two representations apart. If you front the site with a CDN and would rather not fragment its HTML cache, setEnableContentNegotiation: false(which also stops theVaryemission) — the.mdURLs keep working.
HTTP caching
All AEO responses (.md pages, /llms.txt, /llms-full.txt) carry a weak ETag
and Last-Modified (from the content's culture-aware update dates), answer
If-None-Match / If-Modified-Since with 304 Not Modified, and support HEAD —
so AI crawlers that re-fetch aggressively can revalidate cheaply. On publish, the
cached llms.txt / llms-full.txt are cleared immediately (InvalidateCacheOnPublish),
with the TTLs (LlmsTxtCacheSeconds / LlmsFullCacheSeconds) as a safety net.
How it works
Request ─┬─ /llms.txt ───────────► AeoContentService.GetLlmsTxt() ─┐
├─ /llms-full.txt ──────► AeoContentService.GetLlmsFullTxt()├─► live content cache
└─ /*.md ───────────────► MarkdownPageMiddleware ───────────┘ (scoped to site root)
│
MarkdownConverterService (HTML/blocks → Markdown)
AeoComposerbinds options, registers the services, and installs anUmbracoPipelineFilterthat maps the two endpoints and inserts the.mdmiddleware (pre-routing) — this is what makes install zero-config.AeoSiteResolvermaps every request to the correct content root and culture using Umbraco's domain configuration (multi-site and multilingual safe), and seeds theVariationContextso content resolves in that culture.AeoPageResolvermaps a page path to published content within that scope — shared by the.mdsuffix handler andAccept: text/markdownnegotiation.AeoResponseHeadersMiddleware(post-routing) decorates rendered HTML content pages with the Markdown-alternateLinkheader andVary: Accept;AeoAlternateLinkTagHelperComponentadds the matching<link>in<head>.MarkdownConverterServiceturns a content node into Markdown by walking its properties generically.AeoContentServiceselects the indexable pages and assemblesllms.txt/llms-full.txt, caching per (site root, culture) withETag/Last-Modifiedvalidators;AeoCacheInvalidationHandlerclears those caches on publish.
License
Free to use, including commercially — not open source. See the LICENSE.md packed with this package. Paid Pro features and consulting are available from Flowcourier ApS.
© 2026 Flowcourier ApS. All rights reserved.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Flowcourier.Umbraco.Foundation (>= 17.5.3)
- HtmlAgilityPack (>= 1.12.4)
- ReverseMarkdown (>= 5.4.0)
- Umbraco.Cms.Web.Website (>= 17.5.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 17.5.0 | 69 | 7/15/2026 |
| 17.0.0-alpha.5 | 35 | 7/15/2026 |