feat: LLM discoverability sync from kurrentschrift - #10808
Conversation
Carry the sister project's assistant-protocol findings (kurrentschrift PRs #428, #435, #439, #440, 2026-08-28) over to anyplot. Assistants' fetch tools often allow only URLs that already appeared verbatim in fetched content, so the machine guide and one complete, callable example per surface now stand wherever an agent actually reads: - SPA shell: <link rel="alternate" href="/llms.txt"> in the head and a <noscript> block with ABSOLUTE URLs (guide, llms-full.txt, one code endpoint, one render PNG, OpenAPI, MCP) — the fallback for every client the nginx map does not recognise. - Guessed paths resolve: /.well-known/llms.txt -> 302 /llms.txt (nginx; it soft-404'd with the shell), api.anyplot.ai/llms.txt -> 302 to the site file (it 404'd). - Both robots.txt files name llms.txt and the OpenAPI spec (two terse lines at the very top of the site file, a block beside Sitemap:, the API host's comment head); the API host also states the Content-Signal. - The invalid `use=reference` Content-Signal token is dropped — the contentsignals.org vocabulary is exactly search/ai-input/ai-train and a strict parser may discard the whole line over an unknown token. - Every bot page links llms.txt/llms-full.txt from its nav; every implementation page spells out its own code URL and carries a visible <pre><code class="language-json"> retrieval record beside the JSON-LD (HTML-to-Markdown converters drop <script>, keep <pre>). - nginx map + AI_AGENTS gain `xai` (a "xAI-Bot" UA fell through to the empty shell — verified live) and the bare `grok` token that was prerendered but never counted. The map block stays byte-identical with kurrentschrift's. - bot_fetch was mostly dropped by Plausible on the prerender path: cf-connecting-ip there is the app container's Google egress address, a "hosting provider IP". visitor_ip now reads the forwarded list first; nginx's @seo_proxy forwards the crawler in X-Forwarded-For. - New asset_fetch event on bots.anyplot.ai: which implementation's code and which spec's detail assistants fetch through the API (cache misses; documented in plausible.md). - Guards: app/src/routes/seoCoverage.test.ts pins llms.txt <-> route registry <-> robots.txt <-> shell (llms.txt gains the missing /map line); bot-serving-check adds the .well-known redirect, robots/sitemap bypasses, the site card, a real 404, xAI-Bot and a deep-route SPA control. Docs: seo.md "Discoverability for assistants", plausible.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016joUzstV3siE3WqJtbcych
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016joUzstV3siE3WqJtbcych
There was a problem hiding this comment.
🟢 Approval recommended
The changes are internally consistent (tests + docs + nginx/app/api updates align) and the added guards substantially reduce drift/regression risk without introducing confirmed correctness issues.
Pull request overview
This PR improves assistant/LLM discoverability of anyplot’s machine-facing surfaces (llms.txt, OpenAPI, MCP) across the SPA shell, robots.txt, bot-rendered pages, and implementation pages, while also fixing analytics correctness for crawler traffic in the Cloud Run → Cloudflare → API topology.
Changes:
- Add redundant, absolute, “verbatim fetchable” pointers to
llms.txt/llms-full.txt/OpenAPI/MCP across HTML shell, robots.txt, bot nav, and implementation pages (including a visible JSON retrieval record). - Fix Plausible bot event attribution by preferring the leftmost valid
X-Forwarded-Forentry for analytics (visitor_ip), and add explicit forwarding in nginx for the prerender hop. - Add “drift guard” tests and monitoring updates (Vitest coverage coupling, expanded bot-serving-check) plus new
asset_fetchanalytics for single-asset API reads.
File summaries
| File | Description |
|---|---|
tests/unit/api/test_seo_helpers.py |
Updates assertions to keep the new retrieval-record <pre><code class="language-json"> while still excluding source-code blocks when code is absent. |
tests/unit/api/test_routers.py |
Adds coverage for API-host robots.txt pointers, /llms.txt redirect behavior, bot-nav absolute links, and asset_fetch vs navigation/machine-file exclusions. |
tests/unit/api/test_analytics.py |
Extends agent detection (xAI/Grok tokens), verifies visitor_ip ordering, and adds tests for classify_asset + track_asset_fetch. |
docs/reference/seo.md |
Documents the discoverability strategy and the corrected Content-Signal vocabulary and API-host redirect behavior. |
docs/reference/plausible.md |
Documents new asset_fetch event and the visitor-IP forwarding rationale/caveat (cache misses). |
CHANGELOG.md |
Adds [Unreleased] entries covering the discoverability sync and analytics fixes. |
app/src/routes/seoCoverage.test.ts |
Adds a Vitest drift-guard coupling paths.ts ↔ llms.txt ↔ robots.txt ↔ index.html shell fallback. |
app/public/robots.txt |
Adds top-of-file pointers to llms/OpenAPI and removes invalid use=reference token; reiterates machine-guide block near sitemap. |
app/public/llms.txt |
Adds missing /map route link to keep machine guide aligned with public routes. |
app/nginx.conf |
Adds ~*xai bot-map entry, forwards X-Forwarded-For on @seo_proxy, and adds /.well-known/llms.txt redirect. |
app/index.html |
Adds <link rel="alternate" href="/llms.txt"> plus a <noscript> absolute-URL fallback with complete example URLs. |
api/routers/seo.py |
Adds full-URL machine links to bot nav, adds visible per-implementation retrieval record, updates API robots.txt, and redirects API-host /llms.txt to site guide. |
api/request_context.py |
Changes visitor_ip to prefer leftmost valid X-Forwarded-For before cf-connecting-ip for Plausible analytics correctness. |
api/main.py |
Extends middleware to emit asset_fetch for single-asset API reads and ensures /seo-proxy is only bot_fetch. |
api/analytics.py |
Adds xAI/Grok bare-token detection, introduces classify_asset, and implements track_asset_fetch event emission. |
.github/workflows/bot-serving-check.yml |
Expands synthetic monitoring (xAI-Bot UA, .well-known/llms.txt redirect, robots/sitemap bypasses, og-image served as PNG, deep-route SPA control). |
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🟢 Approval recommended
The changes are well-covered by targeted unit and drift-guard tests, with only a minor naming clarity nit identified in the middleware tuple unpacking.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
api/main.py:201
- In
record_bot_fetch, the unpacked tuple element is namedkind, but it actually represents the asset type ("code"/"spec"). Usingkindhere is easy to confuse with the assistant "kind" (e.g.,user_directed,training) used elsewhere in analytics; renaming improves readability and reduces the chance of wiring the wrong value intotrack_asset_fetch.
asset = classify_asset(path)
if asset is not None:
kind, spec_id, library_id = asset
track_asset_fetch(request, asset=kind, spec=spec_id, library=library_id, status=response.status_code)
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Copilot review nit on #10808: the middleware unpacked classify_asset's first element as `kind`, which reads like the agent kind (user_directed, index, …) used everywhere else in analytics. It is the asset type (code · spec) — name it so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016joUzstV3siE3WqJtbcych
There was a problem hiding this comment.
🟢 Approval recommended
The changes are internally consistent across nginx/app/API/docs, and the new/updated test coverage meaningfully guards the intended discoverability and analytics behavior against drift.
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
…10809) ## Summary - The `open-pr` skill's §4 post-merge deploy watch ran `gcloud builds list` without `--region=europe-west4`. The `deploy-app` / `deploy-api` triggers are regional, so the global list answers with months-old builds — all `SUCCESS`, none from today — which reads like "nothing was triggered" while both deploys are already done. The #10808 follow-through polled that list for 20 minutes before noticing. - The command now names the region, adds a `SHORT_SHA` column to match against the merge commit, and the trap is recorded next to the command. Changelog entry under Changed. ## Plan N/A — friction found during the #10808 follow-through. ## Test plan - [x] `gcloud builds list --region=europe-west4 --limit 3 --format=…` run as written: shows `deploy-app` and `deploy-api` for `87f78ad` (the #10808 merge), both `SUCCESS`; the global form shows three April builds - [x] Docs-only change; no code paths touched --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
…-elided path or the bare/llms.txtconvention leaves the API unreachable. So the guide and one complete, callable example per surface now stand at every place an agent actually reads: the SPA shell (<link rel="alternate" href="/llms.txt">+ a<noscript>block with absolute URLs — the fallback for every client the nginx map does not recognise), bothrobots.txtfiles (two terse lines at the very top of the site file, a block besideSitemap:, the API host's comment head), every bot page's nav, and every implementation page (its own code URL spelled out + a visible<pre><code class="language-json">retrieval record beside the JSON-LD, because HTML-to-Markdown converters drop<script>and keep<pre>). Guessed paths resolve instead of failing:/.well-known/llms.txt→ 302 (it soft-404'd with the shell, verified live),api.anyplot.ai/llms.txt→ 302 to the site file (it 404'd).bot_fetchon the prerender path was very likely mostly dropped by Plausible: on the hop Cloud Run app → Cloudflare → API,cf-connecting-ipis the app container's Google egress address — exactly the "hosting provider IP" Plausible discards — andvisitor_ippreferred it over the forwarded list where the crawler stands. kurrentschrift measured one counted read in twenty with probe events; anyplot has the same topology.visitor_ipnow reads the leftmost validx-forwarded-forfirst, and@seo_proxyforwards the crawler explicitly. (2) Theuse=referenceContent-Signal token was invalid (vocabulary is exactlysearch/ai-input/ai-train; a strict parser may discard the whole line) — dropped from both groups.$is_botmap gains~*xai(axAI-BotUA got the empty shell — verified live) with kurrentschrift's exact comment, so the block stays byte-identical (checked withdiffagainst kurrentschriftorigin/main);AI_AGENTSgains the baregrokandxaitokens the map already served but never counted (needs mirroring in kurrentschrift — Todoist task filed). Newasset_fetchevent onbots.anyplot.airecords which implementation's code / which spec's detail assistants fetch through the API (cache misses — those routes stay edge-cached for the SPA; documented inplausible.md). Guards: newapp/src/routes/seoCoverage.test.tspins llms.txt ↔ route registry ↔ robots.txt ↔ shell (llms.txt gains the missing/mapline it surfaced);bot-serving-checkadds the.well-knownredirect, robots/sitemap bypasses, the site card, a real 404,xAI-Botand a deep-route SPA control. Doctrine:docs/reference/seo.md"Discoverability for assistants".Plan
N/A — assessment in session; source findings: kurrentschrift
docs/reference/crawler-richtlinie.md§3 "Auffindbarkeit" and its PRs #428, #435, #439, #440.Test plan
uv run pytest tests/unit tests/integration— 1713 + 67 passed;ruff check/format --checkclean;mypy api corecleancd app && yarn lint && yarn fm:check && yarn type-check && yarn test && yarn build— 623 tests, build clean;dist/index.htmlcarries the head link and the<noscript>block with absolute URLs/robots.txtcarries Content-Signal + pointers,/llms.txt→ 302https://anyplot.ai/llms.txt, bot nav ends withllms.txt · llms-full.txt,/seo-proxy/box-basic/python/plotlyrenders the retrieval record with real render/interactive URLs andquality_scoreapp/public/robots.txtparsed withurllib.robotparser: Bytespider denied everywhere,/debugand/interactivedenied for*,/llms.txtallowed — the two new top comment lines change nothingbot-serving-check.ymlrun block extracted and executed locally against the Cloud Run origin: 29 checks OK; exactly the two checks that need the new nginx config (xAI-Bot→ prerender,.well-known/llms.txt→ redirect) fail until the app deploy lands — expected: the daily run will be red until the frontend Cloud Build after merge, then greencurl -sI https://anyplot.ai/.well-known/llms.txt(302 →/llms.txt),curl -s https://api.anyplot.ai/robots.txt(pointer lines),curl -s -A "xAI-Bot/1.0" https://anyplot.ai/scatter-basic | grep -o '<title>[^<]*'(per-route title), and dispatch Bot Serving Check once by handasset_fetchgoal and theasset,spec,libraryproperties onbots.anyplot.aiin Plausible (Todoist task); watch whether the 06:23 UTC bot-serving run now shows ~25bot_fetchevents on the bot site (that is the live proof of the visitor-IP fix)