← BACK TO DIARY
BUILD LOG12 MIN READ

Shipping LLM Prompts as a Build Artifact, Not a Doc

How @madisonlogic/ui-components treats per-component prompt YAML as source of truth, regenerates it into llms.txt, and gates drift with an automated contract check — real gaps and fixes from the sessions that built it.

PUBLISHED:August 10, 2026
TRACK:ARCHITECTURE
TAGS:
#frontend#claude-code#mcp#architecture

1. Executive Summary & Impact

Problem Statement: An Angular component library wants AI coding assistants (Claude Code, Cursor, Windsurf) to generate correct usage of its ~30 components: right imports, right module paths, right gotchas (e.g. ml-select needs [labelKey]/[valueKey] for object items). Hand-written docs drift from the actual public API the moment someone adds an input or renames a method. The library needed prompt content treated like generated code — single source of truth, regenerated, gated, never hand-patched.

Measurable Impact: Not independently benchmarked with a stopwatch, but documented outcomes from the sessions that built this:

  • An automated export-coverage + prompt-contract gate (added 2026-06-12, tools/test-runner/drift-check/) now fails CI when a prompts/*.prompt.yml’s declared module/import_name/source doesn’t match the real export surface, catching drift before it reaches a shipped llms.txt.
  • At least 4 confirmed documentation gaps (see Pitfalls) were caught by agents actually failing to generate correct code against the served prompt, which is the harness’s explicit purpose (see feedback_mcp_fix_not_generated memory).

2. Technical Implementation & Architecture

Step-by-Step Breakdown:

  1. Source of truth is YAML, not markdown. Every component gets a prompts/`<component>`.prompt.yml describing its public API, gotchas, accessibility notes, and canonical usage patterns. Cross-cutting concerns get their own file: _tokens.prompt.yml (design tokens), _mixins.prompt.yml (shared SCSS mixins), _utils.prompt.yml (export utilities), _charts.prompt.yml, _library.prompt.yml (governance rules like “never use native HTML when an ml-component exists”).
  2. Generation step. pnpm run generate:prompts runs prompts/scripts/generate-prompts.ts, which reads all YAML sources and emits prompts/generated/*.prompt.md (per-component, gitignored, rebuilt every time) plus a single bundled components/llms.txt that ships inside the published npm package for any AI tool that wants raw context without an MCP server running.
  3. Verification step. pnpm run generate:prompts:check diffs the regenerated output against what’s committed. That’s what makes “I edited the YAML” provable in CI, as distinct from “I hand-edited the generated file and it happens to look right.”
  4. Contract gate. prompt-contract.ts (part of tools/test-runner/drift-check/) parses every .prompt.yml’s module/import_name/source fields and validates them against the real TypeScript export graph (export-graph.ts walks export * chains and named re-exports). A component can be marked internal: true to skip the import check and instead render an “Internal component” banner in the generated docs. That’s for portal/createComponent-created components with no importable public class, e.g. column-filter-dropdown. (A separate, coarser allowlist mechanism covers other portal-created components like badge/tooltip/toaster at the gate level directly.)
  5. Consumption paths. Two of them: (a) the shipped llms.txt for zero-setup AI context, (b) an MCP server (ml-ui-mcp) that serves the same prompt content as live tools (get_component, list, search_components, etc.). See Topic 3 for the deterministic-tooling angle.

Code Snippets (shape, from the generation/gate scripts described above):

// tools/test-runner/drift-check/prompt-contract.ts (shape)
for (const yml of promptYamlFiles) {
  const { module, import_name, source, internal } = parsePromptYaml(yml);
  if (internal) { assertRendersInternalBanner(yml); continue; }
  const exportsSurface = buildExportSurface(module); // export-graph.ts
  if (!exportsSurface.has(import_name)) {
    fail(`${yml}: import_name "${import_name}" not found in ${module} export surface`);
  }
}
# prompts/select.prompt.yml — gotcha entry that had to be added after a real agent failure
gotchas:
  - "Object items REQUIRE [labelKey] and [valueKey]. Without [valueKey], selectionChange emits the full object. Use primitive string arrays when possible."

★ Insight ───────────────────────────────────── The generation pipeline treats YAML the way the Angular compiler treats .ts: a compile input with a checked contract, not prose. That’s why prompts/generated/ is gitignored. Anything regeneratable from source shouldn’t be reviewed as a diff, only its source should be. And generate:prompts:check is what actually enforces this. Without it, nothing stops someone from hand-patching the generated .md and having it silently diverge from the YAML the next time CI regenerates. ─────────────────────────────────────────────────

3. Pitfalls & Anti-Patterns

What failed during development (real, from session history):

  1. YAML colon-in-string parse trap. A gotcha string written as TypeScript import: import { ... } was parsed by the YAML loader as a mapping (key: value) instead of a plain string, all because of the colon+space. The served prompt rendered the value as [object Object], and an agent following that broken guidance used the wrong directive name (MlTemplateDirective instead of MlTableTemplateDirective). Fix: quote any YAML list item containing : .
  2. Angular template as casts. Agents generated (valueChanged)="x.set($event.value as string)" in a template. Angular’s template parser doesn’t support TypeScript’s as keyword for narrowing. That wasn’t documented anywhere the agent could see it, so it became an explicit governance rule in _library.prompt.yml rather than something assumed as “obvious TS knowledge.”
  3. Native-HTML-instead-of-ml-component drift. Nothing in the prompts explicitly forbade <select> when <ml-select> existed, so agents reached for native controls by default, following their training bias. Fixed by adding an explicit negative rule, not just documenting the positive API.
  4. The temptation to patch generated output directly. When a generated test component fails to compile, the fast fix is to hand-edit the generated .component.ts. We banned that mid-project. Patching output masks the documentation gap and invalidates the very thing being tested, which is whether the served docs alone are sufficient to generate correct code. The enforced loop: find the YAML gap → fix the YAML → regenerate → delete the generated test artifact → re-dispatch the agent from a clean slate.
  5. Keeping two coverage configs in sync by hand. sonar-project.properties sonar.coverage.exclusions and vite.config.mts coverage.exclude have to mirror each other, or files silently report 0% coverage without any test failing. That’s a drift class adjacent to the prompt-YAML drift problem, with the same root cause: generated or derived config diverging from its source of truth.

How to avoid these pitfalls: Treat every “the agent got it wrong” moment as a documentation-gap ticket against the YAML, never a one-off code patch. Gate the YAML→export contract in CI so a renamed export or changed module path fails the build instead of shipping a stale llms.txt.


4. Blog Post (Draft)

Shipping LLM Prompts as a Build Artifact, Not a Doc

When we decided our Angular component library should be usable by AI coding assistants (Claude Code, Cursor, Windsurf), the first instinct was the obvious one. Write good markdown docs, point the assistant at them, done. That lasted about as long as the next unrelated PR that renamed a component input.

Documentation written for a human reader tolerates staleness in a way documentation for an LLM consumer doesn’t. A person hits a wall, greps the source, moves on. An AI agent takes the doc at face value and generates code against it, confidently and wrong. We watched this happen directly. An agent used MlTemplateDirective instead of the correct MlTableTemplateDirective, and not because the docs were silent: a YAML authoring mistake (a gotcha string containing : that got parsed as a mapping instead of plain text) rendered the guidance as [object Object]. The agent did what any reasonable reader does with garbage input. It ignored it and guessed.

That incident, plus a handful like it, forced a shift in how we thought about the problem. If an LLM is going to be a first-class consumer of documentation, that documentation needs the rigor we already give source code: a single source of truth and a gate that fails loud when the compiled output would diverge from reality.

What we built. Every component in the library gets a prompts/`<component>`.prompt.yml holding its public API surface, known gotchas, accessibility notes, and canonical usage. Cross-cutting concerns (design tokens, shared SCSS mixins, export utilities, chart factories, library-wide governance rules) get their own YAML files rather than being folded into every component doc. A generator script (generate-prompts.ts) compiles all of this into two outputs: a bundled llms.txt shipped inside the published npm package, so any AI tool gets usable context with zero setup, and a set of per-component .prompt.md files consumed by an MCP server for tools like get_component and search_components.

Neither output is meant to be hand-edited, ever. They’re gitignored precisely so nobody is tempted to patch a generated file and have it look fine until the next regeneration silently reverts the patch. The only sanctioned way to change AI-facing behavior is: edit the YAML, run generate:prompts, run generate:prompts:check to prove the regenerated output matches, commit both.

The gate that actually enforces it. Good intentions don’t stop drift. CI does. A prompt-contract check walks every YAML’s declared module, import_name, and source fields and validates them against the real TypeScript export graph, built by following export * chains and named re-exports through the actual compiled library. If a YAML claims a component is importable from a path where it no longer lives, the build fails before that stale claim ever reaches a published llms.txt. Components that are legitimately internal (created via createComponent/portal, like column-filter-dropdown) get an explicit internal: true escape hatch that renders a banner instead of a broken import assertion. A few other portal-created components (badge, tooltip, toaster) instead use a coarser allowlist at the gate level.

The rule that mattered most, in practice. When a generated component failed to compile during testing, the fast fix was always sitting right there: open the generated .component.ts, patch the broken line, move on. We banned that outright. The entire point of the exercise is to prove the served documentation is sufficient for an agent to generate correct code unassisted, and patching the generated output masks the actual gap while invalidating the test. The enforced loop instead: find which YAML fact is missing or wrong, fix the YAML, regenerate everything, delete the broken generated artifact, and re-dispatch the agent from a clean slate. Slower per incident, yes. It’s also the only version of the loop that improves the docs instead of laundering around them.

Concrete gaps came out of this process. Agents used TypeScript as casts inside Angular templates, which the template parser doesn’t support and which nothing had previously flagged. Agents reached for native <select> before an equivalent <ml-select> existed, because nothing explicitly told them not to. Agents passed object items into ml-select without [labelKey]/[valueKey], and the resulting selectionChange silently emitted [object Object] downstream. Each one became a permanent, quotable rule in the relevant YAML. Not a one-off fix: a standing fact the next agent, and the next contributor, inherits automatically.

So the rule we ended up with, and the one I’d hand to anyone whose docs have an LLM downstream of them: a wrong output gets fixed at the source, never at the symptom. Everything else — the YAML, the generator, the contract gate — exists to make that rule enforceable.


5. Distribution & Social Assets

LinkedIn Post

We stopped writing docs for our AI coding assistant and started compiling them instead.

Our Angular component library ships to Claude Code / Cursor / Windsurf via an llms.txt and an MCP server. Early on the docs drifted from the real API constantly. A renamed export, a new required input, and suddenly the AI was generating code that didn’t compile.

The fix wasn’t “review docs more carefully.” It was: stop treating prompt content as prose.

  • Every component’s AI-facing docs live in a YAML file, not markdown.
  • A generator compiles all YAML into the shipped llms.txt + per-component prompt files.
  • A CI gate cross-checks every YAML’s declared import path against the actual TypeScript export graph. If they diverge, the build fails.
  • When an AI agent generates broken code against our docs, we never patch the broken output. We fix the YAML gap that caused it, regenerate, and re-test from scratch.

That last rule mattered most. Hand-fixing the symptom is always tempting. But the whole point of shipping docs for an AI consumer is proving the docs alone are sufficient, and patching around a gap just hides it until the next person hits the same wall.

If an LLM reads your docs, those docs need the rigor you give code: a source of truth, a build step, and a gate that fails loud on drift.

Twitter / X Thread

1/ Shipped an AI-facing llms.txt for our component library. First version: hand-written markdown. It drifted from the real API within weeks.

2/ Fix: YAML source of truth per component → generator compiles it → CI gate checks every declared import against the real export graph. Docs became a build artifact, not prose.

3/ Real gap we hit: a gotcha string with a colon ("import: { X }") got silently parsed as a YAML mapping instead of a string. Served prompt rendered [object Object]. The agent then generated broken code following the garbled doc.

4/ Rule that mattered most: when an AI agent generates bad code from our docs, never patch the generated code. Fix the YAML, regenerate, delete the bad output, retry clean. Patch the symptom and the doc gap lives forever.

5/ If your docs have an LLM as a reader, give them the rigor you give code: single source, compiled output, gated contract.

Visual Diagram Prompt

Mermaid prompt: Create a flowchart with four stages left to right: “prompts/.prompt.yml (source of truth)” → “generate-prompts.ts (compiler)” → two parallel outputs: “components/llms.txt (shipped in npm package)” and “prompts/generated/.prompt.md (gitignored)” → “MCP server / AI assistant (consumer)”. Below the compiler node, branch down to a red gate box labeled “prompt-contract.ts: validates module/import_name against real TS export graph — fails build on drift” with an arrow back up into the compiler stage labeled “CI check”. Use a clean left-to-right pipeline style, muted blues/grays, red only for the gate box.

Article Hero Image / Asset Generation Prompt

AI image prompt: A clean, modern editorial illustration for a technical blog post about “compiling documentation for AI consumers.” Show a stylized assembly line or pipeline: on the left, a stack of small YAML/config file icons feeding into a central gear-shaped “compiler” node; on the right, the compiler outputs two branches — one to a glowing document icon labeled subtly as a text file, one to a small robot/assistant icon reading it. A red warning-shield icon sits below the compiler with a feedback arrow looping back into it, representing a validation gate. Flat vector style, soft muted blue/slate/gray palette with a single red accent, generous whitespace, no photorealism, no readable body text in the image itself — icon-level abstraction only. 16:9 aspect ratio suitable for a blog hero banner.

References & Sources

  • Angular template syntax reference — for the as-cast limitation context
  • YAML spec on flow scalars — background on the colon-parsing trap
  • Repo-relative: prompts/scripts/generate-prompts.ts, tools/test-runner/drift-check/prompt-contract.ts, tools/test-runner/drift-check/export-graph.ts, CLAUDE.md “Prompt sync” table