API Documentation Best Practices for AI Tools: Generators, Specs
API documentation best practices for AI tools start with one decision - the spec is the source of truth and the human-readable page is a build artifact of it. Publish a machine-readable schema, attach machine-readable metadata to every operation, test the prose against the code so a deprecated transport cannot survive in an example, and normalise whatever your generator emits before an agent…
Short answer: API documentation best practices for AI tools start with one decision - the spec is the source of truth and the human-readable page is a build artifact of it. Publish a machine-readable schema, attach machine-readable metadata to every operation, test the prose against the code so a deprecated transport cannot survive in an example, and normalise whatever your generator emits before an agent reads it. The pages that matter most are the ones an agent can parse without guessing.
Key takeaways
- Ship the spec, gate the docs behind a flag you can turn off.
openapi_enabledreads one environment variable (SMARTGATE_OPENAPI_ENABLED) and defaults tofalse: the schema exists in code, and publishing it is a deliberate deployment decision. - Descriptions are an API surface.
tool_annotationsattaches a humantitleand areadOnlyHintper tool - the two fields a calling agent uses to decide whether an operation is safe to run unattended. - Test the docs the way you test the code. Three tests in this article assert that
AGENTS.mdlists every tool, that no docs page recommends a retired transport, and that the connection guide names the current one. - Enforce a metadata contract on every page.
collectMarkdownFileswalks the docs tree andparseFrontmatterfails closed: a file without a frontmatter block is not silently accepted, it isnulland skipped. - Normalise generator output before publishing.
process_tablesinserts the separator row that Markdown parsers require, andGFMProcessorcloses unterminated code fences - the two defects that break a generated reference fastest. - Version the payload, not the prose.
parseGatewayPolicyis a v1 schema with a safe parse and a defaulted fallback, so a malformed document degrades instead of throwing.
The short version for whoever owns the developer experience
Documentation used to have one reader. It now has two, and they fail differently. A human skims, forgives a stale endpoint and asks in chat. An agent parses, does not forgive, and will confidently call the endpoint your page described two versions ago. The llms.txt proposal - published in September 2024 and revised to v2 in August 2026 - puts the problem plainly: web pages are built for people, wrapping their information in navigation and JavaScript, and converting that back into clean text "is difficult and imprecise" (llms.txt). Thousands of sites now publish a machine-readable entry point; the same argument applies inside your API reference.
Two anchors are worth having in hand before the practices below. The OpenAPI Specification
- v3.2.1, published 10 September 2026 - exists precisely to give an API "a standard, programming language-agnostic interface description" (OpenAPI Specification), which is the artifact an agent should be reading instead of your prose. And the Model Context Protocol's tool definitions make the stakes concrete: a tool's name and description are what a client "uses to understand the tool" before it decides to call it (MCP tools) - your documentation is not adjacent to the integration, it is part of the interface. The MCP specification and schema docs are the worked example: dated revisions, a schema you can diff, and no prose you have to parse to find out what changed.
SmartGate is an MCP-native algorithm gateway for token control, traffic shaping, and agent
audit, and its own API surface is documented under exactly these constraints: seven
smart_* tools (fetch, search, context gate, dedup, budget guard, memory, pipe), a public
MCP endpoint per client, and a reference that is tested rather than trusted. The code in
this article is that reference infrastructure, quoted from the shipped implementation.
What that audit surface is worth once it is turned into a per-team cost report is worked through on AI financial analysis for agents.
openapi_enabled: the flag that decides whether your schema is published
The first best practice is boring and decisive: make publishing the machine-readable contract an explicit switch, and default it to off.
# backend/smartgate/core/openapi_env.py — source lines 8–10 (openapi_enabled)
def openapi_enabled() -> bool:
raw = os.environ.get("SMARTGATE_OPENAPI_ENABLED", "false").strip().lower()
return raw in ("1", "true", "yes", "on")
Read the default as the design. The schema exists in code either way, but a deployment that
has not opted in does not expose generated documentation pages or a schema endpoint - the
variable is parsed against a positive list (1, true, yes, on) rather than being
truthy-tested, so a stray SMARTGATE_OPENAPI_ENABLED=FALSE or an empty string leaves the
surface closed. Public APIs want the opposite default. Internal or preview surfaces want
exactly this one, and it is the pattern to copy when an API is documented but not yet
promised.
tool_annotations: machine-readable metadata for agent consumers
If the spec is the contract, annotations are the parts of it an agent reads first. They are worth designing deliberately instead of letting a generator infer them:
# backend/smartgate/api/mcp_tool_docs.py — source lines 63–67 (tool_annotations)
def tool_annotations(name: str) -> ToolAnnotations:
return ToolAnnotations(
title=TOOL_TITLES.get(name),
readOnlyHint=name in READ_ONLY_TOOLS,
)
Three fields do the work. title is what a UI shows and what a model quotes when it
explains which tool it used. readOnlyHint is the safety signal: an operation marked
read-only can be retried, batched and run unattended, while one that writes cannot - and
that distinction is what a calling agent needs to avoid re-running a mutation. Both are
derived from a single registry (TOOL_TITLES, READ_ONLY_TOOLS) rather than written per
page, which is the mechanism that keeps annotations from drifting away from behaviour. The
same split between a machine-readable schema and human-facing prose runs through the
MCP Tools Reference, where a tool's inputSchema, title
and readOnlyHint are read as one entry in a tools/list response.
Whether an operation marked read-only may actually be run unattended is a policy decision, and where that policy is enforced once for every caller is the control plane on AI agent governance.
test_agents_md_lists_all_smart_tools: the tool list is asserted, not maintained
The single most common API-docs defect is a list that was complete when it was written. The fix is to make completeness a test:
# backend/tests/docs/test_mcp_docs_sync.py — source lines 38–41 (test_agents_md_lists_all_smart_tools)
def test_agents_md_lists_all_smart_tools():
text = AGENTS.read_text(encoding="utf-8")
for name in TOOL_DESCRIPTIONS:
assert name in text, f"missing {name} in AGENTS.md"
This is four lines and it eliminates a whole class of support thread - "the docs do not
mention the tool I am looking at". The loop is generated from the same registry the tools
are registered in (TOOL_DESCRIPTIONS), so adding an eighth tool breaks the build until
the agent-facing document lists it. That is what "documentation as a build artifact" means
in practice: the failure mode becomes a red test rather than a stale page.
test_all_docs_mdx_do_not_recommend_legacy_sse: deprecations need enforcement
Deprecating something is not the hard part; removing it from a hundred pages is. The pattern that works scans the docs corpus for phrases that must no longer appear:
# backend/tests/docs/test_mcp_docs_sync.py — source lines 44–55 (test_all_docs_mdx_do_not_recommend_legacy_sse)
def test_all_docs_mdx_do_not_recommend_legacy_sse():
"""Scan content/**/docs/*.mdx — no legacy SSE as current transport (Spec §7)."""
paths = sorted(CONTENT.rglob("**/docs/*.mdx"))
assert paths, "expected at least one docs/*.mdx under content/"
for path in paths:
text = path.read_text(encoding="utf-8")
for line in text.splitlines():
for phrase in DOCS_FORBIDDEN:
assert _line_allowed_for_phrase(line, phrase), (
f"{path.relative_to(CONTENT.parent)} line {line!r} "
f"contains forbidden legacy phrase {phrase!r}"
)
Note what this test is not: it is not a spell-check over the whole page, it is a targeted
list of retired phrases (DOCS_FORBIDDEN) with a per-line escape hatch for the places the
word is legitimately mentioned - a migration note, for instance. The scan covers every
docs/*.mdx under content/, and it asserts that it found at least one file, so a path
change cannot turn the whole check into a silent no-op.
test_connect_docs_use_streamable_http: pin the current answer
Forbidden phrases catch what should be gone. The complementary test catches what should be present, on the one page that gets copied into other people's configs:
# backend/tests/docs/test_mcp_docs_sync.py — source lines 58–62 (test_connect_docs_use_streamable_http)
def test_connect_docs_use_streamable_http():
for path in CONTENT.rglob("**/connect.mdx"):
text = path.read_text(encoding="utf-8")
assert "Streamable HTTP" in text or "streamable-http" in text
assert "Generic SSE" not in text
Between the two tests you get a bidirectional guard: the connection guide must mention the transport that is current and must not contain the label of the retired one. This is cheap, and it is the difference between a deprecation announcement and a deprecation - the announcement lives in a changelog nobody re-reads; the test lives in CI and fails on the next careless edit.
collectMarkdownFiles: coverage is part of the contract
A validation pass is only as good as its file list. Enumerating pages by hand guarantees the newest page is the one that skips validation:
# scripts/validate-trust-evidence.mjs — source lines 45–59 (collectMarkdownFiles)
async function collectMarkdownFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectMarkdownFiles(fullPath)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
return files;
}
The recursion matters more than the eleven lines suggest: the docs tree is nested, and a validator that only reads the top level silently exempts every subdirectory - which is exactly where the deep reference pages live. Collect first, validate second, and assert the collection is non-empty; then "every page passed" means every page.
parseFrontmatter: structured metadata, and failing closed when it is missing
Frontmatter is how a Markdown page participates in a structured system - navigation order, intent tags, canonical URLs, publication state. The parser should therefore be strict about its own preconditions:
# scripts/validate-trust-evidence.mjs — source lines 38–43 (parseFrontmatter)
function parseFrontmatter(content) {
if (!content.startsWith("---")) return null;
const end = content.indexOf("\n---", 3);
if (end === -1) return null;
return content.slice(4, end);
}
Returning null rather than an empty object is the important choice. A page with no
frontmatter block, or an unterminated one, is not a page with empty metadata - it is a page
the caller should reject or route elsewhere. In the validator this function feeds, that
null is what turns "this file has no slot" into a build failure instead of a page that
publishes with defaults nobody chose.
The same discipline — a strict parser over a versioned metadata contract, failing closed — is what makes an audit record readable years later, the evidence side on AI compliance.
GFMProcessor: normalise what the generator emits
Whatever produces the reference - a generator, an HTML-to-Markdown converter, or a model - it emits Markdown that is almost right. The post-processor handles the "almost":
# backend/smartgate/modules/fetch/html_converter.py — source lines 43–67 (GFMProcessor)
@staticmethod
def process_code_blocks(md: str) -> str:
"""确保代码块以 ``` 正确闭合。"""
lines = md.split("\n")
in_code = False
result = []
for line in lines:
stripped = line.strip()
if stripped.startswith("```"):
in_code = not in_code
result.append(line)
if in_code:
result.append("```")
return "\n".join(result)
@staticmethod
def process_task_lists(md: str) -> str:
"""确保任务列表格式正确: - [x] / - [ ]"""
return re.sub(r"- \[X\]", "- [x]", md)
def process(self, md: str) -> str:
md = self.process_code_blocks(md)
md = self.process_tables(md)
md = self.process_task_lists(md)
return md
Three fixes, in a deliberate order. process_code_blocks counts fences and appends a
closing fence when the document ends inside a code block - the defect that swallows the
rest of the page into one grey rectangle. process_task_lists normalises -[X] to -[x],
because parsers differ on the uppercase form. And process() fixes fences first, then
tables, then task lists, because a stray fence changes what the table pass sees. Run the
pass before publishing, not after a reader reports it.
process_tables: the separator row is not optional
Tables are where generated API references break silently, and the reason is a single missing line:
# backend/smartgate/modules/fetch/html_converter.py — source lines 22–41 (process_tables)
@staticmethod
def process_tables(md: str) -> str:
"""确保 Markdown 表格有分隔行。"""
lines = md.split("\n")
result = []
i = 0
while i < len(lines):
line = lines[i]
# 检测表格行:包含 | 且至少有 2 个 |
if line.count("|") >= 2 and "---" not in line:
# 检查下一行是否是分隔行
if i + 1 < len(lines) and "---" not in lines[i + 1]:
# 检查上一行是否也是表格行
if i > 0 and lines[i - 1].count("|") >= 2:
# 插入自动分隔行
cols = line.count("|") - 1
result.append("|" + " --- |" * cols)
result.append(line)
i += 1
return "\n".join(result)
GitHub Flavored Markdown requires a delimiter row between the header and the body. A generator that emits header and rows back to back produces a block that renders as one paragraph of pipes - and to an agent parsing the HTML, the parameters and their types simply are not there. The fix here is conservative in the right way: a delimiter is inserted only when the current line looks like a row (two or more pipes), the next line is not already a delimiter, and the previous line is also a row. Anything ambiguous is left untouched rather than "repaired" into something worse.
pipelineMarkdown: render Markdown through a sanitiser, not a trust fall
Publishing Markdown means turning it into HTML, and that step is a security boundary as well as a formatting one:
# lib/pseo/markdown.ts — source lines 18–27 (pipelineMarkdown)
async function pipelineMarkdown(source: string): Promise<string> {
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, sanitizeSchema)
.use(rehypeStringify)
.process(source);
return String(file);
}
The chain is the practice: parse (remarkParse), enable GFM so tables and task lists work
(remarkGfm), convert to HTML (remarkRehype), sanitise against an allow-list schema
(rehypeSanitize), then stringify. Skipping the sanitise step is the mistake that turns a
contributor's <script> tag into a stored XSS on your docs domain - and API references
collect user-supplied content more often than marketing pages do: examples, error messages,
community snippets.
Sanitising is one control on a boundary that also spans tool permissions, server trust and injected content — the wider runtime surface on AI agent security.
parseGatewayPolicy: versioned schemas with a defaulted fallback
Any document your API accepts is a contract with a version, and the parser is where that promise is kept or broken:
# lib/settings/schemas/gateway-policy.v1.ts — source lines 35–42 (parseGatewayPolicy)
function parseGatewayPolicy(raw: unknown): GatewayPolicyV1 {
const empty = raw === null || raw === undefined || raw === "" ? {} : raw;
const parsed = gatewayPolicyV1Schema.safeParse(empty);
if (parsed.success) {
return parsed.data;
}
return gatewayPolicyV1Schema.parse({});
}
Four details are worth copying. The schema is named for its version (...v1), so a v2 is a
new module rather than a breaking edit. Absent input is normalised to an empty object
before parsing, so null and "" take the same path as an empty object {}. The parse is a safeParse -
a result, not an exception - and the fallback is the schema's own defaults, meaning a
malformed document yields a usable, documented baseline instead of a 500. And it is
parse on the way out, so the returned object is the validated type, not the caller's
guess.
The same parser seen from the request path — what the plan clamps it to, who may edit it, and how the key on an incoming call is validated — is worked through on secure prompt handling in AI applications.
generateStaticParams: publish the reference, and keep the URL
The last practice is plumbing that decides whether any of the above is reachable:
# app/[locale]/(docs)/docs/[[...slug]]/page.tsx — source lines 45–55 (generateStaticParams)
async function generateStaticParams(): Promise<
{ locale: string; slug: string[] }[]
> {
const locales: Locale[] = ["en", "zh"];
return locales.flatMap((locale) =>
getAllDocs(locale).map((doc) => ({
locale,
slug: doc.slugAsParams ? doc.slugAsParams.split("/") : [],
})),
);
}
Two things are being declared here. The docs exist per locale - en and zh in this
build, from one source of truth - and every page becomes a known route with a typed shape
(locale, slug[]), enumerated from getAllDocs() rather than hand-listed. For an API
reference that means the URL you put in a support answer, in a model prompt or in
llms.txt resolves to a page that actually exists in the build, in the right language,
instead of a 404 that erodes trust in the whole corpus.
How SmartGate compares
The comparison that matters is not "which tool renders Markdown" but "who guarantees the page is still true".
| What it produces | Where correctness comes from | What you pay | |
|---|---|---|---|
| Hand-written reference | Prose that matched the API on the day it was written | Reviewer attention | Writing time, and drift you discover from a support ticket |
| Generator SaaS (Redocly, Scalar, Mintlify and similar) | A rendered reference from your spec, with hosting and theming | The spec, plus the vendor's template | Subscription per seat or project (Redocly, Scalar, Mintlify) |
| Docs-as-tests in your own repo | The checks that keep any of the above honest: tool-list assertions, forbidden phrases, frontmatter validation, output normalisation | Your test suite, running on every commit | The tests themselves - the code in this article is roughly 120 lines of it |
| SmartGate's own reference | Tool descriptions, annotations and an agent-facing surface among the seven smart_* tools |
The same registry the tools are registered in | Free: 2M tokens/mo, all 7 tools, no card; Pro from $18/mo (pricing) |
An honest reading of the first row: for a small, stable API, hand-written docs plus a capable reviewer are genuinely enough. The tests in this article start paying for themselves at two points - when the API changes faster than the docs are read, and when an agent becomes a consumer with no ability to ask a follow-up question.
How to get started
- Pick the source of truth. Spec first, pages generated or written from it. Decide
whether the schema endpoint is public (
openapi_enabled-style flag on) or internal. - Write for the second reader. Add titles and read-only hints per operation, and put
the machine-readable entry point in
llms.txtalongside your human navigation. - Turn three claims into tests. Every tool/operation is listed in the agent-facing document; no retired phrase appears in the corpus; the current transport is named.
- Copy the shape, not just the advice. The published tools reference is the worked example of this section: per-tool titles, parameters, and the read-only hints an agent can trust. How a user pastes that reference into the assistant they already run is walked through on SmartGate MCP for research and decisions.
- Validate the output, not just the source. Collect every Markdown file, require frontmatter, normalise fences and tables, and render through a sanitiser.
Start on Free - 2M tokens/month, all seven tools, no card: start free, and compare the caps your documentation workload would actually hit on the pricing page.
Frequently Asked Questions
Do I need both an OpenAPI spec and an HTML reference?
Yes, with one rule: the HTML is generated from the spec or checked against it. Two hand-maintained sources of truth guarantee a page that contradicts the schema, and the agent reading the page wins.
What is llms.txt and is it required?
It is a proposed standard for a single Markdown file at /llms.txt that gives agents a concise map of your site - the v2 proposal was updated in August 2026 after two years of adoption. It is not required, and it is cheap; it is one file and one link from your docs home.
How do I stop deprecated endpoints from surviving in examples?
Scan for them. A targeted list of retired phrases plus a per-line allowance for migration notes catches the copy-paste case that review misses, and it runs in the same suite as your units.
Our generator emits tables without delimiter rows. Fix or configure?
Fix, in a post-processing pass you own. The insertion rule is three conditions - looks like a row, next line is not a delimiter, previous line is also a row - and it is safer than a generator upgrade you cannot test.
Should the docs be per-locale?
If your API is, yes - and enumerate the routes from the content, not by hand. Two locales over one source of truth is a build parameter; two hand-maintained trees are two documentation projects.
Where does documentation belong in a gateway like SmartGate?
In the interface. Tool descriptions and annotations are what a calling agent reads before it decides to run something, and the seven smart_* tools are documented from the same registry they are registered in.
Limitations and what this does not do
- Tests keep documentation true, not good. Nothing here tells you whether a page answers the question a developer actually has; structure (for instance the four documentation types in Diátaxis) is a human design decision.
- Assertions age like code. The three doc tests quoted here are the shape to copy, not a package to install; a phrase list that is never revisited becomes the next stale document.
- Normalisation is a safety net, not a generator fix. Inserting delimiter rows and closing fences repairs output; it does not make a poor generator produce correct structure. Fix the source when you can.
- Sanitising has a cost. An allow-list schema will strip something a contributor expected to work. That trade is deliberate: broken formatting in an example is preferable to script execution on your docs domain.
- Numbers quoted from the specification and the llms.txt proposal are dated snapshots (OpenAPI v3.2.1, September 2026; llms.txt v2, August 2026). Check the upstream documents before citing them in your own material.
Sources
- OpenAPI Specification v3.2.1 — the machine-readable interface description: https://spec.openapis.org/oas/latest.html
- llms.txt v2 — proposal for a machine-readable site entry point for agents (published 2024-09-03, revised 2026-08-10): https://llmstxt.org/
- Model Context Protocol — tool definitions, names and descriptions: https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- Diátaxis — a structure for documentation types: https://diataxis.fr/
- Redocly — spec-driven reference documentation tooling: https://redocly.com/
- Scalar — API reference generator and client: https://scalar.com/
- Mintlify — documentation platform: https://mintlify.com/
- Stripe API reference — a widely copied example of a hand-curated reference: https://docs.stripe.com/api
- SmartGate — product site and pricing: https://smartgate.network · https://smartgate.network/pricing
Method note
The code in this article is not transcribed. Each block was cut directly out of the slice
body returned by the SmartGate slice API and then re-asserted byte-for-byte as a substring
of that body before publication; the first line inside every fence records the file and the
exact source lines. Symbols were pinned with whole-name containment (rule A level 2) and
confirmed by the service's slot-proof endpoint before being written into the prose. Two
sections quote different parts of GFMProcessor on purpose - the table path and the fence
path are separate fixes - and the provenance table records the exact ranges.
Slice provenance
| # | SERP keyword | Symbol | File | Source lines | How it was pinned | sha256(12) |
|---|---|---|---|---|---|---|
| 1 | openapi_enabled openapi schema and docs enable flag | openapi_enabled |
backend/smartgate/core/openapi_env.py |
8–10 | rule A L2 → slot-proof | 7683748ab469 |
| 2 | tool_annotations machine readable tool annotations for agents | tool_annotations |
backend/smartgate/api/mcp_tool_docs.py |
63–67 | rule A L2 → slot-proof | a822944005fe |
| 3 | test_agents_md_lists_all_smart_tools agents md lists all smart tools test | test_agents_md_lists_all_smart_tools |
backend/tests/docs/test_mcp_docs_sync.py |
38–41 | rule A L2 → slot-proof | 946e466956cd |
| 4 | test_all_docs_mdx_do_not_recommend_legacy_sse docs test rejects legacy SSE transport | test_all_docs_mdx_do_not_recommend_legacy_sse |
backend/tests/docs/test_mcp_docs_sync.py |
44–55 | rule A L2 → slot-proof | 2b59bc54bc41 |
| 5 | test_connect_docs_use_streamable_http connect docs use streamable http test | test_connect_docs_use_streamable_http |
backend/tests/docs/test_mcp_docs_sync.py |
58–62 | rule A L2 → slot-proof | cf22d54ab45b |
| 6 | collectMarkdownFiles collect every docs markdown file for validation | collectMarkdownFiles |
scripts/validate-trust-evidence.mjs |
45–59 | rule A L2 → slot-proof | 398594403a4f |
| 7 | parseFrontmatter parse frontmatter metadata on every docs page | parseFrontmatter |
scripts/validate-trust-evidence.mjs |
38–43 | rule A L2 → slot-proof | 4149caedcba4 |
| 8 | GFMProcessor GFM processor converts API reference HTML to markdown | GFMProcessor |
backend/smartgate/modules/fetch/html_converter.py |
43–67 | rule A L2 → slot-proof | 84d12fbffda0 |
| 9 | process_tables process tables in API reference markdown | process_tables |
backend/smartgate/modules/fetch/html_converter.py |
22–41 | rule A L2 → slot-proof | ef2adc8a70da |
| 10 | pipelineMarkdown pipeline markdown renderer for docs pages | pipelineMarkdown |
lib/pseo/markdown.ts |
18–27 | rule A L2 → slot-proof | 52ce6db05b40 |
| 11 | parseGatewayPolicy parse versioned gateway policy schema | parseGatewayPolicy |
lib/settings/schemas/gateway-policy.v1.ts |
35–42 | rule A L2 → slot-proof | dd074c32468e |
| 12 | generateStaticParams generate static params for docs routes | generateStaticParams |
app/[locale]/(docs)/docs/[[...slug]]/page.tsx |
45–55 | rule A L2 → slot-proof | f9cfb017e6d0 |
Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 12 of 12 sections pinned, 0 abstentions, 0 misses.