An Astro content-collection starter that treats agents — an HRIS system, a recruiter-facing LLM tool, any agent following a link into your site — as a first-class consumer of your content, the same way a design system treats agents as first-class consumers of design tokens.
Most "make my site AI-ready" advice means bolting on a chatbot. That solves a different problem than the one most sites actually have: an agent that follows a link into your site today gets whatever a human browser would get — paginated HTML, nav chrome, JS-rendered content it may not execute — and has to reconstruct facts from that, with no guarantee the facts it extracts are complete or accurate. A chatbot answers questions about your content from a separate, hand-maintained corpus. This template makes the content itself the single source of truth an agent can request directly.
Content collections are already Astro's single source of truth for the HTML build. This template projects that same source, at build time, into three additional static surfaces:
/llms.txt— a short index: title, tagline, and a linked list of every published item./llms-full.txt— the complete text of every published item in one request, generated from the sameqaContextfield and body content that render the HTML pages.- Per-item markdown mirrors (
/items/[slug].md) — one item's full content as plain markdown, linked from its HTML page via<link rel="alternate" type="text/markdown">. - JSON-LD (
Personsite-wide,CreativeWorkper item) — rendered byLayout.astrofrom whateverjsonLdprop a page passes it.
Everything is generated at build time, not served from a live endpoint. That's a deliberate choice, not an oversight: a static projection has no per-request cost, no rate-limiting surface, and no prompt-injection risk from untrusted runtime input — an entire category of problems a live agent-facing API would otherwise need to solve. If you want a conversational assistant on top of this, that's a separate, additive feature with its own safety surface — this template doesn't take a position on it.
export const qaContext = z.object({
summary: z.string(),
facts: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
scopeNotes: z.string().optional(),
});qaContext is a structured, optional field co-located with the content
it describes — not a parallel corpus maintained separately from your actual
content. That's the load-bearing lesson behind this template: an earlier,
unrelated project this pattern was extracted from had made the opposite
choice (a separately-maintained AI-facing corpus) and paid for it in drift
risk — the corpus and the real content silently disagreed over time. Keeping
qaContext next to the content it describes doesn't guarantee it stays
accurate, but it makes staleness a one-file diff instead of a
cross-repository archaeology problem.
qaContext is optional so you can ship content without it and fill it in
later. src/lib/agent-content.ts tracks which published items are missing it
(missingQaContext), and src/pages/llms-full.txt.ts logs that list as a
build warning — an earlier version of this code computed that list and never
read it anywhere, which is worth naming explicitly: an unread signal is the
same as no signal.
scripts/eval-agent.mjs sends the generated llms-full.txt to Claude as its
only context, asks it a set of ground-truth questions
(scripts/eval-agent.ground-truth.json), and scores the answers against
required/forbidden substrings — not fuzzy matching. Run it with:
yarn eval:agentThis requires ANTHROPIC_API_KEY (or an ant auth status-visible
credential) and builds the site first. It's a floor, not a proof of
completeness: it only catches what the ground-truth file thinks to test. Add
a question every time you add a fact you'd actually want an agent to get
right.
- Rename the
itemscollection insrc/content.config.tsto whatever your primary content type actually is (case studies, posts, docs), and adjust its schema fields — keepqaContextandstatusas-is, sincesrc/lib/agent-content.tsdepends on both. - Replace the two example entries in
src/content/items/with your own content, each withqaContextfilled in. - Edit
src/lib/site-config.ts— the one file the agent-layer code andsrc/lib/schema.tsread your site's name, URL, tagline, and description from. - Edit
src/lib/schema.tsfor your ownPerson/CreativeWorkJSON-LD fields. - Rewrite
scripts/eval-agent.ground-truth.jsonagainst your real content, then runyarn eval:agentto confirm it passes.
yarn install
yarn dev # http://localhost:4321
yarn build # writes dist/, including llms.txt and llms-full.txt
yarn eval:agentThis template was extracted from a larger, three-part redesign of a job-search portfolio site. Two of those three parts are not in this template, on purpose:
- A lens-based entry system (a dismissable, persisted, URL-overridable "who are you and what are you here for" prompt that routed visitors into curated content sequences). The mechanism — a small state machine plus two reusable primitives (an escape hatch, a persistence helper) — is real and well-built, but the actual routing logic assumes a fixed set of personas specific to one job search. Extracting it here would have meant either shipping a template that still secretly assumes "evaluating a candidate for a role" as the visitor's intent, or generalizing a persona model that hasn't been validated against a second use case. Neither was worth doing for a template whose whole point is the agent-layer idea, not the entry UX.
- A two-phase JD-to-fit-brief generator (an offline CLI that read a job
description, matched it against case studies via the same
qaContextfact layer, and produced a static, unlisted brief page). The two-phase generate/review/commit flow and its prompt-injection hygiene (treating pasted input as content to analyze, never as instructions) are genuinely reusable ideas — but the script had never actually been run to produce a real brief in the source project at the time this template was extracted. Generalizing a pattern that hasn't been exercised against a single real input is premature; there was no evidence yet that the flow, the prompt, or the schema were actually right.
Naming what's left out, and why, is itself part of this template's design stance: partial success and deliberate scope cuts are worth stating plainly rather than smoothing over.