Built-in llms.txt

The agent discovery surface — llms.txt, sitemap, robots, api-catalog, the ARD catalog, a generated agent skill, and per-route manifests — derives from your routes automatically.

The feature#

An agent landing on a June app finds its way without scraping. These derive from the route graph and your actions — you author none of them:

surface what an agent learns
/llms.txt a curated index: every page, grouped by section, described, linking its markdown
/sitemap.xml, /robots.txt the classic crawler contract
/.well-known/api-catalog machine-readable API listing (RFC 9727): each API an item, with its description and docs
/.well-known/ai-catalog.json (also /.well-known/ard.json) one catalog of the app's agentic resources — its MCP server, its HTTP API, and its skill — for ARD crawlers (AI Catalog format)
/.well-known/agent-skills/index.json a generated Agent Skill that teaches an agent to use this site (discovery RFC v0.2.0)
/mcp your actions as MCP tools an agent can call
/.well-known/mcp/server-card.json the MCP server's identity and how to connect (when MCP is on)
/openapi.json, GET /api, POST /api/<id> the same actions as plain HTTP — OpenAPI 3.1 for OpenAPI / function-calling clients; GET /api indexes them, and any other miss under /api is a JSON error, not an HTML page
Link response header discovery advertised on every HTML response

It's on by default and one switch turns it all off (agent: { enabled: false }) — the framework's defaults philosophy: removable, not assembly required.

What goes in llms.txt#

llmstxt.org asks for a curated file, not a sitemap: links grouped under H2 sections, each with a one-line description, and an ## Optional section for links an agent can skip when its context is short. June builds that from your routes:

  • A static route is listed under ## Pages, titled and described by its static metadata. A metadata function needs loader data, so its path stands in for the title.
  • A dynamic route ([slug]) is a template an agent can't fetch, so it's listed only if it names its real pages.
  • Every link points at the page's .md projection, so an agent reads the page, not its HTML. A route that turned md off links the page itself.

A route shapes its entries with an llms export:

// app/docs/[slug]/page.tsx: one link per doc, grouped like the sidebar
import type { LlmsEntry } from "@junejs/core/route";
import { docSections } from "../_sections";

export const llms = (): LlmsEntry[] =>
  docSections().flatMap((s) =>
    s.docs.map((d) => ({
      path: `/docs/${d.slug}`,
      title: String(d.data.title ?? d.slug),
      description: d.data.description ? String(d.data.description) : undefined,
      section: s.title || "Docs",
      lastModified: (d.data.updated ?? d.data.date) as string | undefined,
    })),
  );
// app/blog/[slug]/page.tsx: posts are background, so they go under Optional
export const llms = (): LlmsEntry[] =>
  POSTS.map((p) => ({
    path: `/blog/${p.slug}`,
    title: String(p.data.title ?? p.slug),
    description: p.data.description ? String(p.data.description) : undefined,
    optional: true,
    lastModified: (p.data.updated ?? p.data.date) as string | undefined,
  }));

An entry is { path, title?, description?, section?, optional?, lastModified? }. A static route can export a single object (export const llms = { section: "Project" }) to change its section, and export const llms = false leaves a route out. Sections appear in the order they're first seen, and ## Optional is always last, after the tool sections. Optional is llmstxt.org's reserved name, so an entry filed under a section called Optional counts as optional too.

When to use#

An agent choosing between ten tools picks the one that says what it's for. June can't write that for you, but it gives the answer a fixed place: agent.llms.whenToUse renders as a ## When to use list right under the summary, before any other section.

// june.config.ts
export default defineJune({
  agent: {
    llms: {
      whenToUse: [
        "You need an invoice PDF from an order id: call `render_invoice`.",
        "You're reconciling payouts: read /payouts.md for the schedule and fees.",
      ],
    },
  },
});

Write jobs, not slogans: each line should name a task an agent might be doing and what to use for it.

The sitemap lists every page#

/sitemap.xml uses the same page list as llms.txt. A static route is one URL. A dynamic route contributes the pages its llms entries name, which is the same runtime-safe hook llms.txt runs. A [slug] template that names no pages stays out, because a template isn't a URL.

llms = false behaves differently for the two kinds of route. On a static route it only takes the page out of llms.txt; the page stays in the sitemap. On a dynamic route it removes the route's pages from both llms.txt and the runtime sitemap, because the llms entries were the only list of those pages. A static() build still lists them through staticPaths.

staticPaths stays a build-time hook, so a crawler fetching /sitemap.xml never triggers it. The one exception is the static() target, which already runs staticPaths to prerender. Its prerendered sitemap lists those pages too, except with i18n, where they arrive locale-prefixed.

An entry's lastModified becomes that page's <lastmod>: a "2026-09-27" string, a full ISO timestamp, or a Date (a YAML date: field works as-is). June never substitutes the build time. A page with no date gets no <lastmod>, because a wrong date tells crawlers to re-read (or skip) the wrong pages.

export const llms = (): LlmsEntry[] =>
  DOCS.map((d) => ({
    path: `/docs/${d.slug}`,
    lastModified: d.data.updated ?? d.data.date,
  }));

The generated skill and the catalog#

Every app publishes one skill, named after its host (june.build → june-build), at /.well-known/agent-skills/<name>/SKILL.md. It tells an agent how to use the site: start at llms.txt, whose links point at each page's Markdown version; read a page as Markdown where it offers one (the page advertises it with <link rel="alternate" type="text/markdown">; fetch .md or send Accept: text/markdown) or JSON (.json) likewise, since a route that turns a projection off answers 404 there; and call each tool at /mcp, listed with its parameters and description. The index next to it carries the SKILL.md's sha256 digest, so an agent can verify what it downloaded.

The AI Catalog lists the same resources for ARD: the MCP server card, the HTTP API's /openapi.json, and the skill, each with a urn:air:<host>:… identifier, under a did:web:<host> host. robots.txt points at it (Agentmap:), and so do every page's <link rel="ai-catalog"> and Link header. Both catalogs name absolute URLs, so a static build writes them only when it knows the public origin (site.url or deploy.domain) and deploys at the domain root.

A static build prerenders llms.txt and sitemap.xml against a placeholder host, so both name the public origin instead: site.url (its origin), else deploy.domain, plus the deploy basePath. A GitHub Pages project site lists https://user.github.io/repo/…. With neither configured, llms.txt falls back to root-relative links, which llmstxt.org allows. The sitemap protocol requires absolute URLs, so no sitemap.xml is written, and the build prints a warning to set site.url. A static build writes no robots.txt. A basePath site couldn't use one anyway, because robots rules are only read at the domain root.

A static host serves no /mcp either, so a static build renders every discovery surface with MCP projected out: llms.txt drops its MCP claims, the catalogs and skill list no MCP server, and pages register no WebMCP tools.

Try it on this site#

curl https://june.build/llms.txt
curl https://june.build/.well-known/api-catalog
curl https://june.build/.well-known/ai-catalog.json
curl https://june.build/.well-known/agent-skills/index.json
curl -sI https://june.build/why | grep -i '^link:'

The llms.txt here also carries the framework's canonical names — which package is ours (@junejs/core, create-june) and which similarly-named ones are not. Agents misinstalling lookalike packages is a real failure mode; the discovery surface is where you correct it.

Why it matters#

Discovery is the cheapest half of being agent-ready: if an agent's first fetch answers "what is here and how do I read it," everything downstream (markdown projections, MCP tools) gets found instead of guessed at.