API reference
Publish API documentation from a TypeSpec definition
Compile TypeSpec to OpenAPI in the same project as your docs, turn doc comments, summaries, and examples into a page per operation, and fail CI when the committed spec goes stale.
By Hayden Bleasel16 min read

To publish documentation from a TypeSpec API definition, compile it to OpenAPI with the @typespec/openapi3 emitter, write the file beside your Blume config, and mount it with openapi(). TypeSpec and Blume both run on Node.js, so they can share one package.json, and one npm run build compiles the definition and builds the site from it.
By the end of this guide, you have a TypeSpec definition whose doc comments, summaries, and examples fill the reference, a page per operation grouped by tag, a quickstart written beside it, and a CI check that fails when the committed spec no longer matches the definition. The example starts from TypeSpec's own REST template, the Widget Service, and was tested with TypeSpec 1.16.0.
Blume never reads your .tsp files. Every page comes from the OpenAPI document the emitter writes, so the decorators you add in TypeSpec are where the docs get better. If you already have an OpenAPI file and no TypeSpec, start with Generate API docs from an OpenAPI spec instead.
How the pieces fit
The API definition and the docs live in one folder, as one Node.js project:
acme-widgets/
├── main.tsp the API definition
├── tspconfig.yaml where the OpenAPI emitter writes
├── openapi.json written by tsp compile, read by Blume
├── blume.config.ts
├── docs/
│ ├── index.mdx
│ └── quickstart.mdx
└── package.json TypeSpec and Blume, side by sidetsp compile . turns main.tsp into openapi.json. Blume reads that file at build time and renders a page for each operation under /api, next to the Markdown pages in docs/. Blume's content lives in docs/, so the TypeSpec files at the root never become pages.
Start the project
If you already have a TypeSpec project, run the last three commands from its root and skip the first two. Otherwise, create one from TypeSpec's REST template and add Blume to it:
mkdir acme-widgets && cd acme-widgets
npx --package=@typespec/compiler@1.16.0 tsp init --template rest --no-prompt
npm uninstall @typespec/rest
npm install --save-exact @typespec/compiler@1.16.0 @typespec/http@1.16.0 @typespec/openapi@1.16.0 @typespec/openapi3@1.16.0
npx blume init . --template docs --yes
npm install blumetsp initwritesmain.tsp(the Widget Service),tspconfig.yaml,package.json, and a.gitignore, then installs the TypeSpec packages.npx --packageruns it without a global install of the compiler.- The template lists
@typespec/rest, which this API never imports, so the next line removes it. - The install pins the compiler and emitters to exact versions. The CI check later in this guide compares the spec byte for byte, and another emitter release can word or order its output differently. Commit
package-lock.jsontoo. blume init .writesblume.config.tsanddocs/index.mdx, and adds.blume/and.env.localto the.gitignore, which already ignoresdist/andnode_modules/. It leaves the existingpackage.jsonalone and doesn't install anything, which is whynpm install blumefollows.
Emit OpenAPI where Blume reads it
The template's tspconfig.yaml writes the spec to tsp-output/schema/openapi.yaml, a folder its .gitignore ignores. Replace it so the spec lands beside blume.config.ts. In a TypeSpec project of your own, keep its other emitters and change only the @typespec/openapi3 options:
kind: project
emit:
- "@typespec/openapi3"
options:
"@typespec/openapi3":
emitter-output-dir: "{project-root}"
output-file: "openapi.json"
openapi-versions:
- 3.1.0
experimental-parameter-examples: data| Option | What it does here |
|---|---|
emitter-output-dir | Writes into the project root. The emitter's own default is tsp-output/@typespec/openapi3/. |
output-file | Names the file openapi.json; the extension makes it JSON. Blume reads YAML just as well. |
openapi-versions | Writes OpenAPI 3.1. Without this option, the emitter writes 3.0. |
experimental-parameter-examples | Writes the parameter values from @opExample into the spec. Without it they're dropped, and the code samples call /widgets/string. |
3.0, 3.1, and 3.2 all work. Blume upgrades a 3.0 spec to 3.1 as it reads it and reads 3.1 and 3.2 as written, and this guide's definition built the same pages from each. Choose one, though: with more than one in openapi-versions, the emitter writes each into a folder named after it, like 3.1.0/openapi.json, and the path Blume reads moves. TypeSpec marks experimental-parameter-examples as experimental, so check its entry in the emitter's README when you upgrade.
Compile the template once to see what it gives you:
npx tsp compile .Write the definition for readers
The template compiles, but it makes a poor reference. Its operations have doc comments and nothing else, and once you mount the reference in the next section, Blume builds these pages from its spec:
| Operation | Page | Title | Sidebar label |
|---|---|---|---|
Widgets.list | /api/widgets/widgets-list | GET /widgets | GET /widgets |
Widgets.create | /api/widgets/widgets-create | POST /widgets | POST /widgets |
Widgets.read | /api/widgets/widgets-read | GET /widgets/{id} | GET /widgets/{id} |
Three things cause that, and each has a fix in TypeSpec:
- No summaries. TypeSpec writes a doc comment to the operation's
description, never itssummary. Blume titles a page and labels it in the sidebar with the summary, and falls back to the method and path, so the sidebar reads like a list of endpoints. Add@summaryto every operation. - Generated operation IDs. By default the emitter names an operation after its interface and itself, like
Widgets_list. Blume builds the page URL from the tag and the operation ID, so the tag appears twice. Set the ID with@operationIdfrom@typespec/openapi. If an SDK generator reads the same spec, the ID is its method name too, so change it on purpose. Operations declared directly in the service namespace, outside an interface, get their bare name. - No examples. Without them, the code samples and the Try it panel fill in placeholder values like
"string"and0.
Here's the whole definition with those fixed:
import "@typespec/http";
import "@typespec/openapi";
using Http;
using OpenAPI;
/**
* Create, update, and analyze the widgets in your Acme account.
* Every request needs an API key, sent as a bearer token.
*/
@service(#{ title: "Widget Service" })
@info(#{ version: "1.0.0" })
@server("https://api.widgets.example", "Production")
@useAuth(BearerAuth)
@tagMetadata(#[
#{
name: "Widgets",
description: "Create, read, update, and delete widgets.",
},
#{
name: "Analysis",
description: "Run checks on a widget you already created.",
}
])
namespace WidgetService;
/** A widget in your account. */
model Widget {
/** The widget's ID, set by the API when you create it. */
@visibility(Lifecycle.Read)
@example("wdg_8f2k")
id: string;
/** The widget's weight, in grams. */
@minValue(1)
@example(120)
weight: int32;
/** The widget's color. */
@example("red")
color: "red" | "blue";
}
/** A page of widgets. */
model WidgetList {
/** The widgets on this page. */
items: Widget[];
}
/** The error every operation returns when a request fails. */
@error
model Error {
/** A numeric code for the error. */
@example(404)
code: int32;
/** What went wrong, in a sentence. */
@example("No widget has that ID.")
message: string;
}
/** The result of analyzing a widget. */
model AnalyzeResult {
/** The ID of the widget that was analyzed. */
id: string;
/** What the analysis found. */
analysis: string;
}
@route("/widgets")
@tag("Widgets")
interface Widgets {
/** Returns every widget in your account, newest first. */
@summary("List widgets")
@operationId("listWidgets")
@get
list(): WidgetList | Error;
/**
* Returns one widget by its ID, with its current weight and color.
* @param id The widget's ID, from the response to Create a widget.
*/
@summary("Get a widget")
@operationId("getWidget")
@returnsDoc("The widget.")
@opExample(#{
parameters: #{ id: "wdg_8f2k" },
returnType: #{ id: "wdg_8f2k", weight: 120, color: "red" },
})
@get
read(@path id: string): Widget | Error;
/** Creates a widget and returns it with the ID the API assigned. */
@summary("Create a widget")
@operationId("createWidget")
@post
create(@body body: Widget): Widget | Error;
/**
* Changes the fields you send and leaves the rest as they are.
* @param id The ID of the widget to update.
*/
@summary("Update a widget")
@operationId("updateWidget")
@opExample(#{ parameters: #{ id: "wdg_8f2k", body: #{ weight: 150 } } })
@patch
update(@path id: string, @body body: MergePatchUpdate<Widget>):
| Widget
| Error;
/**
* Deletes a widget. This can't be undone.
* @param id The ID of the widget to delete.
*/
@summary("Delete a widget")
@operationId("deleteWidget")
@returnsDoc("The widget is deleted.")
@opExample(#{ parameters: #{ id: "wdg_8f2k" } })
@delete
delete(@path id: string): void | Error;
}
@route("/widgets/{id}/analyze")
@tag("Analysis")
interface Analysis {
/**
* Checks a widget's weight and color against your account's rules.
* @param id The ID of the widget to analyze.
*/
@summary("Analyze a widget")
@operationId("analyzeWidget")
@opExample(#{
parameters: #{ id: "wdg_8f2k" },
returnType: #{
id: "wdg_8f2k",
analysis: "Within your account's weight limit.",
},
})
@post
analyze(@path id: string): AnalyzeResult | Error;
}Where each decorator shows up
| In TypeSpec | In the spec | On the docs site |
|---|---|---|
@service(#{ title }) | info.title | The overview page's title |
| Doc comment on the namespace | info.description | The overview page's introduction |
@info(#{ version }) | info.version | "Version 1.0.0" on the overview page |
@server | servers | The base URL, where Try it sends requests, and the code samples' host |
@useAuth(BearerAuth) | components.securitySchemes and the root security | An Authorization section on every operation, and Authorization: Bearer YOUR_TOKEN in its samples |
@tag | The operation's tags | Its sidebar group, and the URL segment /api/widgets/ |
@tagMetadata | The top-level tags list | The groups' order, and each overview section's description |
@operationId | operationId | The page URL: getWidget becomes get-widget |
@summary | summary | The page title and sidebar label |
Doc comment on the operation, or @doc | description | The paragraph under the title, and the start of the page's meta description |
@param in that doc comment | The parameter's description | The parameter's row |
@returnsDoc | The success response's description | The text beside the response's status code |
| Doc comments on model properties | Property descriptions | The schema tables |
@example on a property | The property's examples | Request samples, Try it's starting values, and response examples |
@opExample | An example on each parameter and on the response body | The path in the code samples, and the Response panel |
@visibility(Lifecycle.Read) | readOnly: true | Left out of the request body table and the request sample |
Compile again and look at one operation. Everything the Get a widget page shows is in it:
npx tsp compile .
jq '.paths["/widgets/{id}"].get' openapi.json{
"operationId": "getWidget",
"summary": "Get a widget",
"description": "Returns one widget by its ID, with its current weight and color.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The widget's ID, from the response to Create a widget.",
"schema": {
"type": "string"
},
"example": "wdg_8f2k"
}
],
"responses": {
"200": {
"description": "The widget.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Widget"
},
"example": {
"id": "wdg_8f2k",
"weight": 120,
"color": "red"
}
}
}
},
"default": {
"description": "An unexpected error response.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"tags": [
"Widgets"
]
}Examples
Put @example on each property rather than on the whole model, so every model and operation that uses a property shares its example. Blume builds the request from them and leaves the read-only id out, while response examples keep it. It does the same with a model-level example, which becomes one example object for the schema, id included.
@opExample is type-checked against the whole operation. That's why Create a widget has none: its body is a Widget, so the example would need an id too. Update a widget's example needs a body beside the id for the same reason, but the emitter writes no request example for a merge-patch body, so that page builds its body sample from the property examples. The id is what matters there: with it, the samples call /widgets/wdg_8f2k.
Tag order
Blume orders the sidebar groups and the overview's sections by the spec's top-level tags list, then any tag it doesn't declare. Use the array form of @tagMetadata, which keeps the order you write. One @tagMetadata("Widgets", …) per tag also works, but the spec then lists the tags in the reverse order of the decorators, and Analysis comes before Widgets.
Mount the reference
Replace the config that blume init wrote:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
title: "Widget Docs",
description: "Guides and API reference for the Widget Service.",
deployment: { site: "https://docs.widgets.example" },
navigation: {
tabs: [
{ label: "Guides", path: "/" },
{ label: "API reference", path: "/api" },
],
},
reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});The reference doesn't add a header tab on its own, so the tab at /api makes it reachable and scopes its sidebar. deployment.site gives the sitemap and canonical links their origin on hosts that don't tell the build its URL, like GitHub Pages.
Write guides beside the reference
The reference says what each operation takes and returns. A reader also needs to know how to authenticate and which call comes first. Replace docs/index.mdx and add a quickstart:
---
title: Introduction
description: Store widgets, update them, and run analysis on them with the Widget Service API, using an API key and any HTTP client.
---
The Widget Service API stores the widgets in your Acme account and checks them
against your account's rules.
- [Quickstart](/quickstart) takes you from an API key to your first analyzed widget.
- The [API reference](/api) lists every operation, generated from the API's TypeSpec definition.---
title: Quickstart
description: Authenticate with an API key, create your first widget with the Widget Service API, and run an analysis on it.
---
Every request needs an API key, sent in the `Authorization` header as a bearer
token. Set it in your shell first:
```bash
export WIDGETS_API_KEY="your-api-key"
```
## Create a widget
Send its weight and color to [Create a widget](/api/widgets/create-widget):
```bash
curl https://api.widgets.example/widgets \
-H "Authorization: Bearer $WIDGETS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"weight":120,"color":"red"}'
```
The response is the widget, with the ID the API assigned:
```json
{ "id": "wdg_8f2k", "weight": 120, "color": "red" }
```
## Analyze it
Pass the ID to [Analyze a widget](/api/analysis/analyze-widget):
```bash
curl -X POST https://api.widgets.example/widgets/wdg_8f2k/analyze \
-H "Authorization: Bearer $WIDGETS_API_KEY"
```
## Handle errors
A failed request returns an error object with a numeric `code` and a
`message`. Every operation's page lists it under its responses.Link to operation pages by their URL, which comes from the tag and the operation ID. Those links are how a definition change that moves a page gets caught: blume validate checks every one of them against the reference.
Inside a tag's group, the sidebar and the overview page list operations in the spec's order, which is path by path, so Create a widget comes before Get a widget. Neither follows the order of your interface. To set the sidebar order yourself, add a meta.ts in the folder that matches the tag's route:
import { defineMeta } from "blume";
export default defineMeta({
title: "Widgets",
order: 0,
pages: [
"list-widgets",
"get-widget",
"create-widget",
"update-widget",
"delete-widget",
],
});A meta.ts you write changes only the fields it sets, and Blume fills in the rest from the spec, so the tag's title and its position in the tag list could be left out. It reorders only the sidebar: the overview keeps the spec's order.
Build and preview
tsp init wrote a package.json with no scripts. Add scripts that compile the definition before every build:
"scripts": {
"spec": "tsp compile .",
"dev": "blume dev",
"build": "tsp compile . && blume build"
},A TypeSpec error stops npm run build before Blume runs, so a definition that doesn't compile never publishes. Build, check the links, and preview the result:
npm run build
npx blume validate --strict
npx blume preview--strict fails on warnings as well as broken links. Open http://localhost:4321/api. The overview lists both tags with their descriptions, and each operation has its own page:
| Operation | Page |
|---|---|
GET /widgets | /api/widgets/list-widgets |
GET /widgets/{id} | /api/widgets/get-widget |
POST /widgets | /api/widgets/create-widget |
PATCH /widgets/{id} | /api/widgets/update-widget |
DELETE /widgets/{id} | /api/widgets/delete-widget |
POST /widgets/{id}/analyze | /api/analysis/analyze-widget |
Each page has its summary as the title, the doc comment under it, an Authorization section for the bearer token, schema tables with the property descriptions, and code samples built from the examples, such as curl -X GET 'https://api.widgets.example/widgets/wdg_8f2k' on Get a widget. That host doesn't exist, so Send in Try it can't succeed here. With your own API, the panel calls the server in @server, and your API has to allow the docs origin; see Fix CORS errors in an API documentation playground.
While you write, run the compiler in watch mode in one terminal and Blume in another. blume dev watches openapi.json, so each save in main.tsp updates the open page without a restart:
npx tsp compile . --watch
npm run devCommit the spec and check it in CI
Commit openapi.json even though it's generated. A change to one decorator can change dozens of lines of spec, and the pull request shows the API change the way clients will see it. Other tools, like an SDK generator or a breaking-change check, can read the file straight from Git. And because npm run build compiles first, the docs host rebuilds the spec from main.tsp anyway, so a stale commit never reaches the site.
What can go stale is the committed copy. Add a workflow that compiles the definition and fails when that changes the file:
name: Docs
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- name: Check the committed spec is up to date
run: |
npx tsp compile .
git diff --exit-code -- openapi.json
- run: npx blume validate --strict
- run: npm run buildThe compiler writes the same bytes for the same definition, and the pinned versions keep CI on the compiler you ran locally. Say a pull request adds a label property to Widget but nobody compiled. The check fails and prints what the commit is missing:
"examples": [
"red"
]
+ },
+ "label": {
+ "type": "string",
+ "description": "A name for the widget, shown in your dashboard.",
+ "examples": [
+ "Front door sensor"
+ ]
}
},
"description": "A widget in your account."A second hunk adds the same property, nullable, to the merge-patch schema TypeSpec derives for Update a widget. Run npm run spec and commit the result. git diff only compares files Git tracks, so commit openapi.json before you add the check. If nothing but the docs reads the spec, you can skip all this: add openapi.json to .gitignore, let the build script write it, and run npm run spec before npm run dev in a fresh clone. You lose the reviewable diff. To also fail a pull request that breaks existing clients, add oasdiff as shown in Keep API documentation in sync with backend changes in CI.
Document every version of a versioned API
With @typespec/versioning, one definition describes several API versions, and the emitter writes one spec per version. Add the package, then mark the namespace as versioned and say when things were added:
npm install --save-exact @typespec/versioning@0.86.0 import "@typespec/http";
import "@typespec/openapi";
+import "@typespec/versioning";
using Http;
using OpenAPI;
+using Versioning;
/**
* Create, update, and analyze the widgets in your Acme account.
* Every request needs an API key, sent as a bearer token.
*/
@service(#{ title: "Widget Service" })
-@info(#{ version: "1.0.0" })
+@versioned(Versions)
@server("https://api.widgets.example", "Production")
...
namespace WidgetService;
+/** The versions of the Widget Service API. */
+enum Versions {
+ v1: "1.0",
+ v2: "2.0",
+}
+
...
@route("/widgets/{id}/analyze")
@tag("Analysis")
+@added(Versions.v2)
interface Analysis {@versioned sets each file's info.version to that version's value and overrides @info's, so drop @info. Then put {version} in the file name, where the emitter fills in the same value:
output-file: "openapi.{version}.json"That writes openapi.1.0.json and openapi.2.0.json, and only the 2.0 file has the Analysis operation. The old openapi.json stays behind, so delete it with git rm openapi.json, and point the CI check at the new files with git diff --exit-code -- 'openapi.*.json'. Then give each file its own source and route, and put both under a dropdown tab:
export default defineConfig({
// ...title, description, and deployment as before
navigation: {
tabs: [
{ label: "Guides", path: "/" },
{
label: "API reference",
path: "/api",
items: [
{ label: "Version 2.0", path: "/api/v2" },
{ label: "Version 1.0", path: "/api/v1" },
],
},
],
},
reference: [
openapi({
sources: [
{ label: "Version 2.0", route: "/api/v2", spec: "./openapi.2.0.json" },
{
label: "Version 1.0",
route: "/api/v1",
spec: "./openapi.1.0.json",
noindex: true,
includeInSearch: false,
includeInLlms: false,
},
],
}),
],
});The two versions share most of their operations, so without the last three options blume audit warns about a dozen pages with duplicate titles and descriptions, and about operation pages that are identical in both versions with no canonical. noindex keeps the 1.0 pages out of search engines and the sitemap, which clears those warnings. The other two keep them out of site search and llms.txt, so readers and agents land on the current version; leave them out if 1.0 readers should still search their own pages.
Every operation now lives under its version's route, like /api/v2/widgets/get-widget. Update the links in docs/ (blume validate lists each one you miss, including the old /api, which no longer has a page), and move docs/api/widgets/meta.ts to docs/api/v2/widgets/, with a copy in docs/api/v1/widgets/. The tab's sidebar holds both versions, sorted by name, which puts 1.0 first. A meta.ts in each version's folder sets the order and shows one version at a time:
import { defineMeta } from "blume";
export default defineMeta({
title: "Version 2.0",
display: "page",
order: 1,
});Add the same file in docs/api/v1/ with its own title and order: 2. Combine multiple OpenAPI specifications in one docs site covers routes, search, and overlapping names when several specs share a site. Blume's own versioning doesn't apply here: it snapshots the pages in docs/, and generated references always stay current.
Deploy
The site builds to static files in dist/. On your host, use the build script rather than blume build, so every deploy compiles the definition first:
| Setting | Value |
|---|---|
| Build command | npm run build |
| Output directory | dist |
| Node.js version | 22.19 or later |
TypeSpec 1.16 needs Node.js 22 and Blume needs 22.19, so the second is the floor. The deployment docs cover each host, and Deploy Markdown docs to GitHub Pages adds a deploy job to a workflow like the one above.
Limitations
- Only what the emitter writes reaches the docs. Blume reads the OpenAPI file, not TypeSpec, so a decorator the openapi3 emitter doesn't translate, like an
@exampleon a path parameter, changes nothing on the site. - Parameter examples are experimental. They need
experimental-parameter-examples, which TypeSpec may change. Without it, path values in the samples fall back to placeholders. - Merge-patch bodies get no request example. The emitter drops the
bodyof an@opExampleon aMergePatchUpdateoperation, so property examples are the only way to shape that sample. - Agents get the body and responses, not every table. The Markdown copy of each operation page, which
llms-full.txtand the.mdURLs serve, carries its summary, doc comment, and endpoint, the request body's top-level properties and example, and each response with its example, but not the parameters or nested schemas. Write the doc comments so they stand on their own.
Troubleshooting
URLs read widgets-list instead of list-widgets
The operation has no @operationId, so the emitter named it Widgets_list. Add one, and if the old URLs are already public, add a redirect for each in blume.config.ts.
The code samples call /widgets/string
The path parameter has no example. Add an @opExample with the parameter's value, and check that tspconfig.yaml sets experimental-parameter-examples: data. An @example on the parameter itself isn't written to the spec.
tsp compile fails with "Property 'id' is missing"
An @opExample body has to be a complete instance of the body's type, read-only properties included. Drop the @opExample from that operation and rely on property examples, as Create a widget does, or give the operation a body type without the property, like Create<Widget>.
The Analysis group comes before Widgets
The tags are declared with one @tagMetadata each, which writes them in reverse. Use the array form.
The build fails with BLUME_OPENAPI_UNAVAILABLE
Blume couldn't find openapi.json. blume build on its own doesn't compile TypeSpec, so run npm run build, or npm run spec first. In blume dev a missing spec is only a warning and the reference is skipped, so a working dev server can hide it.
Next step
Add docs to your TypeSpec project
Run it at the root of your TypeSpec project, point openapi() at the file tsp compile writes, and give every operation a @summary.
npx blume init . --template docs --yesA step here not working for you? Report a broken step.