fix(app): reach the AI clients the UA gate and empty shell were losing - #10488
Conversation
App-side half of the 2026-08-19 AI-access audit fixes (api side: #10487): - nginx UA map: ~*grok (bare 'Grok' token fell through — the reported 'insufficient relevant content'), Amazonbot, meta-externalagent (both documented tokens the amzn-*/meta-externalfetcher entries never matched), Diffbot, Firecrawl - llms.txt rewritten for machine consumers: REST API section, GCS render URL pattern, GitHub raw URLs, worked fetch-one-plot recipe, honest note that prerendered HTML is UA-gated - anyplot.ai/llms-full.txt proxied to the API's generated catalogue index (was a soft-404: SPA shell with HTTP 200) - noscript fallback in the SPA shell pointing JS-less clients at llms.txt, llms-full.txt, the API and GitHub - bot-serving-check: new UA tokens + llms-full.txt assertion, timeout recomputed for 23 checks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An agent asked for 'bar charts' had no documented way to search: the filter endpoint's params (plot=, lib=, lang=, spec=, data=, dom=, feat=; comma=OR, repeated=AND) are now in the API section with a live-verified example, and the fetch-one-plot recipe points at grepping llms-full.txt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves machine/agent access to anyplot’s catalogue by fixing user-agent (UA) allowlisting gaps that caused some AI/crawler clients to receive the empty SPA shell, and by adding explicit machine-oriented discovery entrypoints (llms.txt + llms-full.txt) plus monitoring to prevent regressions.
Changes:
- Extend nginx bot UA mapping (including broadening Grok matching and adding documented Amazon/Meta tokens) and add an nginx proxy for
/llms-full.txtto the API-generated catalogue index. - Rewrite
app/public/llms.txtto document reliable, UA-independent retrieval paths (API, GCS renders, GitHub raw source) with a concrete “fetch one plot” recipe. - Add a
<noscript>fallback to the SPA shell and extend the daily bot-serving monitor to cover the new UA tokens and/llms-full.txt.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
CHANGELOG.md |
Documents the crawler allowlist fix, noscript fallback, and improved llms.txt + /llms-full.txt behavior under [Unreleased]. |
app/public/llms.txt |
Updates llms.txt to be machine-consumable with API/GCS/GitHub retrieval guidance and a worked example. |
app/nginx.conf |
Expands UA bot mapping and adds location = /llms-full.txt proxying to the API endpoint. |
app/index.html |
Adds a <noscript> fallback directing JS-less clients to machine-readable entrypoints. |
.github/workflows/bot-serving-check.yml |
Extends synthetic monitoring coverage for additional UAs and asserts /llms-full.txt content. |
💡 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.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
app/public/llms.txt:10
- The llms.txt intro says non-allowlisted clients "get an empty app shell", but this PR adds a fallback in app/index.html, so JS-less clients (including most fetchers that don't execute JS) will now receive that fallback content. Consider rewording to clarify they won’t get the prerendered per-route HTML (they’ll get the SPA shell plus a minimal fallback), and that the API/raw URLs are UA-independent.
every plot page carries the runnable source code (MIT-licensed). catalog pages are served as
prerendered HTML to recognised crawler user agents — if your client is not on that list you
get an empty app shell, so prefer the API and raw URLs below: they work for every client,
no special user agent required.
Copilot review on #10488: the timeout comment claimed 23 checks; the script performs 22 retried check() calls plus one non-retried trailing-slash probe. Ceiling unchanged (38 min). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
app/public/llms.txt:21
- The responsive image filename examples are inconsistent: it lists
plot-light_400.pngbut then only_800,_1200(missing theplot-lightprefix). Elsewhere in the repo the convention isplot-light_800.png/plot-light_1200.png, so this is likely to mislead clients copying the URL pattern.
`https://storage.googleapis.com/anyplot-images/plots/bar-error/python/seaborn/plot-light.png`
(dark theme: `plot-dark.png`; smaller: `plot-light_400.png`, `_800`, `_1200`; WebP: `plot-light.webp`).
## Summary
- Fixes MCP `get_implementation` for the non-Python 28% of the
catalogue: the repository call carried no language, and its `python`
default made all 1,004 R/Julia/JavaScript implementations answer a false
"not found". The language now comes from the library's own DB row
(verified live for ggplot2/makie/d3/echarts/chartjs/muix/highcharts in
the audit).
- Fixes every discovery tool's `website_url`: `list_specs`,
`search_specs_by_tags` and `get_spec_detail` pointed at
`anyplot.ai/python/{spec}`, which 301s into a 404; all now point at the
hub. `get_spec_detail`'s per-implementation URLs — silently discarded by
`SpecDetailResponse` coercion — are attached after `model_dump` and
survive.
- Makes stateless MCP HTTP real: `stateless_http=True` is passed
explicitly to `http_app()`. The former
`os.environ.setdefault("FASTMCP_STATELESS_HTTP", "true")` ran after
fastmcp's `Settings` were instantiated and never engaged (reproduced
against fastmcp 3.4.5), leaving sessions pinned to one Cloud Run
instance with max-instances=3 and no affinity.
- Adds a `libraries` filter to `get_spec_detail` — the full 15-library
response carries ~0.5 MB of source, a context-window hazard for the
agents the tool serves.
- Gives the bot-served `/mcp` page its actual content (endpoint URL,
`claude mcp add` snippet, the six tools, JSON-API/llms-full.txt
fallbacks) — it rendered as title + one line for the exact audience it
exists for.
- Documents the `anyplot-images` bucket CORS policy (applied 2026-08-19)
in the project guide with the re-apply procedure — bucket metadata does
not deploy with the repo, so a rebuild would silently drop it.
## Plan
The targeted MCP pass from the 2026-08-19 AI-access audit — the final
step of the fix order after #10487 (api) and #10488 (app), as agreed.
Branch is rebased on the merged main.
## Test plan
- [x] `ruff check` + `ruff format --check` clean; `mypy api core` clean
- [x] 1,681 unit + 67 integration tests pass, including new tests:
language resolution from the library row (python and r), `website_url`
correctness on all three tools, per-implementation URLs surviving the
Pydantic roundtrip, the `libraries` filter, and the `/mcp` bot-page
content
- [x] fastmcp behavior verified directly: `settings.stateless_http`
stays `False` under the old env-setdefault route and `http_app` accepts
`stateless_http` explicitly
- [ ] After deploy: MCP `initialize` + `tools/call get_implementation
{spec_id: "audiogram-clinical", library: "ggplot2"}` returns code (was
"not found"); a request without `mcp-session-id` is accepted
(stateless); `curl -A GPTBot https://anyplot.ai/mcp` shows the tool list
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
## Summary - `llms.txt` and `robots.txt` carry UTF-8 punctuation (em dashes, arrows) but were served as bare `text/plain` without a charset — a strict client decodes them as Latin-1 mojibake (found by the machine-files verifier in the 2026-08-19 live sweep; `llms-full.txt`, generated by the API, already declares `charset=utf-8`). - `charset utf-8;` in both nginx server blocks (main + python.anyplot.ai). It applies to nginx's default `charset_types`; proxied responses that already declare a charset keep theirs. - The daily bot-serving monitor now asserts the charset on `llms.txt` (timeout comment recomputed: 22 retried checks + two non-retried probes). ## Plan One of the three "for later" items from the AI-access verification sweep, done on request. Independent of #10490 and #10491. ## Test plan - [x] Config-only change following the existing block structure; no local nginx syntax check possible here (no docker) — same known verification gap as #10488, flagged per CLAUDE.md - [ ] After merge + deploy: `curl -sI https://anyplot.ai/llms.txt | grep -i content-type` → `text/plain; charset=utf-8`; same for `robots.txt`; the extended daily monitor guards it from then on --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
~*groknow catches the bareGroktoken xAI's fetcher was seen sending (it fell through to the empty SPA shell — the exact "insufficient relevant content" a real Grok session reported), and adds the documentedAmazonbotandmeta-externalagenttokens that the existingamzn-*/meta-externalfetcherentries never matched, plus best-effortDiffbot/Firecrawl.llms.txtfor machine consumers: a REST API section (base URL, retrieval endpoints, OpenAPI link), the GCS render URL pattern (themes, responsive widths, WebP), CORS-open GitHub raw source URLs, a worked three-step "fetch one plot" recipe, and an honest note that prerendered page HTML is UA-gated while these URLs work for every client.anyplot.ai/llms-full.txtby proxying the API's generated whole-catalogue index (endpoint shipped in fix(api): open the machine interface AI agents were locked out of #10487) — previously the URL was a soft-404 answering 200 with the homepage shell.<noscript>fallback to the SPA shell so JS-less clients not on the UA allowlist see pointers tollms.txt,llms-full.txt, the JSON API and GitHub instead of a completely empty page.llms-full.txtassertion (timeout recomputed for 23 checks).Plan
App-side half of the fix order from the 2026-08-19 AI-access audit (steps 4 and 5); the api-side half is #10487.
Merge order: #10487 first, then this PR. The
llms-full.txtnginx location proxies to the API endpoint that #10487 adds — merging this first would 404 that one path until the API deploys (everything else in this PR is independent).Test plan
yarn lint,yarn fm:check,yarn type-check,yarn test,yarn buildall green;noscriptblock verified present indist/index.htmllocation = /llms.txt+@seo_proxypatterns verbatim (proxy headers, TLS verify depth 4); no local nginx syntax check possible in this environment (no docker) — flagging per the known Cloud-Build-deploy verification gapcurl -A "Grok/1.0" https://<origin>/scatter-basicreturns the prerendered title;curl https://anyplot.ai/llms-full.txtreturns the catalogue index;curl -A "Amazonbot/0.1" …prerendered — the extended bot-serving-check covers all three daily