Building AI Agent Skills as Build Artifacts
Every API doc page on requiems.xyz has a “copy as markdown” button that dumps a fully-formed doc a user can paste straight into their agent. That part was never the hard part. The hard part is that the copy is a one-time paste: the moment the underlying doc changes, every already-pasted copy silently drifts out of date, with nothing to notice or fix it. Multiply that by every user who’s pasted a given API’s docs into their own agent config, and you have a slow, invisible staleness problem with no way to correct it in bulk.
So we stopped treating “docs for agents” as a document at all, and started treating it as a build artifact — the same YAML that already powers our public docs pages compiles into an OpenAPI spec, into installable AI-agent skills, into an MCP server, and into five language SDKs. Nobody hand-writes any of them. This is a walkthrough of that pipeline, using our actual production setup as the worked example, so you can steal the pattern for your own API.
The source of truth
Every endpoint in Requiems API is described once, in a plain YAML file, not in prose:
# apps/dashboard/config/api_docs/advice.yml (trimmed)
api_id: advice
api_name: Random Advice
description: Get random pieces of advice and wisdom for inspiration, daily motivation, or content generation.
base_url: https://api.requiems.xyz
endpoints:
- name: Get Random Advice
method: GET
path: /v1/entertainment/advice
description: Returns a random piece of advice
parameters: []
response_example: |
{
"data": {
"id": 42,
"advice": "Don't compare yourself to others. Compare yourself to the person you were yesterday."
}
}
response_fields:
- name: id
type: integer
description: Unique identifier for the advice
- name: advice
type: string
description: A random piece of advice
Sixty of these live under apps/dashboard/config/api_docs/, one per API, and
a Minitest suite (api_docs_test.rb) enforces the schema on every PR — every
endpoint needs name/method/path/description, every path has to start
with /v1/, every parameter needs a location (path, query, or body —
deliberately not OpenAPI’s in, so this format doesn’t accidentally couple
itself to OpenAPI’s vocabulary). That schema check is what makes everything
downstream trustworthy: nothing consumes this YAML without knowing it’s
already valid.
The fan-out
From that one file, four independent things get generated, and none of them know about each other:
apps/dashboard/config/api_docs/*.yml
│
├──▶ apps/workers/auth-gateway (scripts/openapi/*.ts)
│ └──▶ src/generated/openapi.ts ──▶ served at
│ https://api.requiems.xyz/openapi.json
│ │
│ ├──▶ apps/mcp (fetch-spec.ts + generate.ts)
│ │ └──▶ generated/tools/*.ts (MCP tool wrappers)
│ │
│ └──▶ requiems-api-clients (weekly workflow)
│ └──▶ openapi-generator-cli ──▶ TypeScript /
│ C# / Python / Ruby / Go SDKs
│
├──▶ requiems-api-skills (scripts/build/index.ts, reads YAML directly)
│ └──▶ skills/<api>-<method>-<path>/SKILL.md ──▶ published to npm
│
└──▶ apps/dashboard/app/helpers/apis_helper.rb
└──▶ human-facing docs page + "open in Claude/ChatGPT" link
The OpenAPI spec itself is never hand-edited — auth-gateway’s
package.json runs generate:openapi as a predev and predeploy hook, so
the spec regenerates from the YAML on every local dev boot and every
deploy. It’s structurally impossible for the served /openapi.json to lag
behind the source docs.
Two of the four branches — the MCP server and the client SDKs — go through
that generated /openapi.json. The skills package doesn’t: it reads the
YAML directly, on purpose, which is worth its own section.
Skills as a build artifact, not a document
@requiems/api-skills is an npm package that installs SKILL.md files —
plain Markdown with a small YAML front-matter header — into an agent’s skills
directory. That shape (front-matter + Markdown body) is the deliberate compile
target, because it’s the lowest common denominator across Claude Code,
OpenCode, and GitHub Copilot’s agent-skills support. No proprietary schema, no
SDK to install.
The transform is one pure function, buildSkillMarkdown() in
requiems-api-skills/scripts/build/index.ts: it takes a parsed YAML doc and
one of its endpoints, and returns a Markdown string. No file I/O, no CLI
parsing inside it — that mattered directly when this script migrated off Deno
onto Node: the markdown-building logic needed zero changes, only the I/O shell
around it did.
Running it against advice.yml produces:
---
name: advice-get-advice
api: Random Advice
method: GET
path: /v1/entertainment/advice
base_url: https://api.requiems.xyz
description: Returns a random piece of advice
---
## Endpoint
**GET https://api.requiems.xyz/v1/entertainment/advice**
## Get Random Advice
Returns a random piece of advice
## Response Example
```json
{
"data": {
"id": 42,
"advice": "Don't compare yourself to others. Compare yourself to the person you were yesterday."
}
}
```
## Response Fields
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | integer | Unique identifier for the advice |
| `advice` | string | A random piece of advice |
One file per endpoint, not one per API — skills/advice-get-advice/SKILL.md,
not skills/advice.md. That’s deliberate: a single file covering ten
endpoints means every regeneration touches that one file, and a PR reviewer
can’t tell which endpoint actually changed without reading the whole diff.
Per-endpoint files keep the diff scoped to whatever actually moved upstream.
Regeneration, without tracking state
The weekly job (.github/workflows/regenerate-skills.yml) doesn’t try to
figure out what changed in requiems-api since last time — that would mean
keeping state, and state drifts. Instead it checks out both repos fresh,
reruns the entire transform from scratch, and lets git status --porcelain
tell it what’s different:
- name: Checkout requiems-api (source of YAML docs)
uses: actions/checkout@v7
with:
repository: bobadilla-tech/requiems-api
path: requiems-api
- name: Regenerate skills
run: |
node scripts/build/index.ts \
--source requiems-api/apps/dashboard/config/api_docs \
--output ./skills
- name: Detect changes in skills/
id: diff
run: |
if [ -n "$(git status --porcelain -- skills/)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Bump patch version
if: steps.diff.outputs.changed == 'true'
run: npm version patch --no-git-tag-version
The version bump is gated on the diff, not on the cron tick firing. A weekly job that always bumps the version — even on a no-op week — produces a version-only PR every single week, forever. Gating on the actual diff means a quiet week produces no PR at all, and a missed run isn’t a lost event, either — the next run just regenerates from scratch and produces a bigger diff.
That regeneration opens a PR. It does not publish anything. Publishing is a
second, separate, human-triggered step: pushing a vX.Y.Z tag fires
publish.yml, which cross-checks the tag against package.json before
touching npm at all —
- name: Verify tag matches package.json version
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PKG_VERSION="$(node -p "require('./package.json').version")"
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag v$TAG_VERSION does not match package.json version $PKG_VERSION"
exit 1
fi
- name: Publish to npm
run: npm publish --access public --provenance
— and publishes using npm’s Trusted Publishing (OIDC), so there’s no
long-lived npm token sitting in repo secrets. “This reflects current docs”
(merge) and “this is now public” (tag push) are two decisions a maintainer
makes at different times, on purpose. The tempting version of this pipeline
auto-publishes whenever main changes; we didn’t build that, because it means
nothing reaches the public registry that a human didn’t explicitly decide to
ship — which matters a lot more once real users depend on the package.
Design decisions worth stealing
- One generated file per endpoint. Keeps regeneration diffs reviewable.
- Gate the version bump on the diff, not the schedule. No-op weeks produce no noise.
- Decouple merged from published. A docs update should never silently become a public release.
- Guard the publish step by cross-checking tag against package version. Three lines of bash beats discovering the mismatch as a cryptic npm 403.
- Keep the toolchain boring. This script briefly ran on Deno for its nicer
--allow-read/--allow-writepermission model, while the rest of the repo was Node. In practice the script never touched anything untrusted, so the sandboxing bought nothing — it just meant two lockfiles and an extrasetup-denostep to run one file. Consolidating onto Node’s native TypeScript stripping (nots-node, no bundler) removed a whole toolchain for free.
Applying this to your own API
- Make sure your API docs are structured data somewhere — YAML, JSON, a database table — not prose. If they’re a wiki page today, write that structured layer first; there’s no reliably transforming free text.
- Write one pure function: doc in, target format out (Markdown for a skill, an OpenAPI operation object, whatever your target is).
- Wrap it in a CLI you can run on your own laptop and get the same result CI gets.
- Schedule a job that reruns the transform from scratch and diffs the output, rather than tracking what changed upstream.
- Keep publishing a separate, explicit action from merging.
- Mark every generated file
// AUTO-GENERATED — do not edit, and pick one boring runtime to generate it with.
Where it’s still rough
Two honest gaps, since this is a production system and not a case study: the
requiems-api-clients pipeline regenerates all five language SDKs weekly and
opens a PR, but there’s no publish step wired up yet — nothing pushes to npm,
PyPI, NuGet, or RubyGems automatically, unlike the skills package. And because
openapi-generator-cli’s packageName/gemName options were never set for
every language, a couple of the generated clients still carry generator
boilerplate names (Org.OpenAPITools for C#, openapi_client as the Ruby gem
name) instead of anything Requiems-branded.
Both are next on the list — the pattern proved itself on the skills side first, so that’s the one we’re writing about today.