Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Add SDK examples to generated API documentation

Operation pages that open on your TypeScript and Python SDK calls, with the generated HTTP samples kept or dropped, and a check that catches samples an SDK release broke.

By 10 min read

Yes. Add an x-codeSamples list to an operation in your OpenAPI spec, with one entry per SDK call. Blume shows each entry as its own tab on the operation's page, ahead of the cURL, JavaScript, and Python samples it generates from the spec, and one setting decides whether those generated samples stay.

By the end of this guide, the Acme Messages API reference opens each operation on a TypeScript SDK tab, then a Python SDK tab, then a raw cURL request. You also get a small check that type-checks every sample against the SDK version your docs describe, so an SDK release can't leave a broken call on the page.

This builds on Generate API docs from an OpenAPI spec, and assumes a reference mounted at /api from openapi.yaml. Blume doesn't write SDK code: it generates plain HTTP requests. If you don't publish an SDK, those generated samples already cover this, and you only need to pick their languages. If Speakeasy or Stainless builds your SDK, they can write the samples for you, which the section on SDK releases covers.

Add SDK calls to an operation

x-codeSamples is an extension Redocly defined, and other docs tools read it too. It goes on the operation object, beside operationId, not on the path. Here are both message operations with a TypeScript and a Python call each. The rest of the spec stays as it was:

paths:
  /messages:
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Send a message
      description: Queues an email or SMS for delivery and returns its ID right away.
      x-codeSamples:
        - lang: typescript
          label: TypeScript SDK
          source: |
            import { Acme } from "@acme/messages";

            const acme = new Acme({ apiKey: process.env.ACME_API_KEY });

            const message = await acme.messages.send({
              channel: "email",
              to: "ada@example.com",
              template: "welcome",
            });
            console.log(message.id);
        - lang: python
          label: Python SDK
          source: |
            import os

            from acme_messages import Acme

            acme = Acme(api_key=os.environ["ACME_API_KEY"])

            message = acme.messages.send(
                channel="email",
                to="ada@example.com",
                template="welcome",
            )
            print(message.id)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendMessageRequest"
            example:
              channel: email
              to: ada@example.com
              template: welcome
      responses:
        "202":
          description: The message is queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
  /messages/{id}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message
      description: Returns a message and its delivery status.
      x-codeSamples:
        - lang: typescript
          label: TypeScript SDK
          source: |
            import { Acme } from "@acme/messages";

            const acme = new Acme({ apiKey: process.env.ACME_API_KEY });

            const message = await acme.messages.get("msg_8f2k");
            console.log(message.status);
        - lang: python
          label: Python SDK
          source: |
            import os

            from acme_messages import Acme

            acme = Acme(api_key=os.environ["ACME_API_KEY"])

            message = acme.messages.get("msg_8f2k")
            print(message.status)
      parameters:
        - name: id
          in: path
          required: true
          description: The message ID, from the response to Send a message.
          schema:
            type: string
          example: msg_8f2k
      responses:
        "200":
          description: The message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Message"
        "404":
          description: No message has that ID.

Each entry has three fields:

  • lang picks the syntax highlighting and the tab's default name. typescript shows as TypeScript and python as Python. bash, sh, shell, and zsh show as Shell, which suits an SDK's command-line tool.
  • label is optional and replaces that default name.
  • source is the code, as a string. The | block keeps its line breaks, and Blume trims the final newline.

The older spelling, x-code-samples, works too, and so do Swagger 2.0 and OpenAPI 3.0 specs.

See the tabs

The dev server doesn't watch a spec file beside your content folder, so restart it after editing the spec, then open /api/messages/send-message:

npm run dev

The page's Request panel now has five tabs, in this order:

TabWhere it comes from
TypeScript SDKYour first x-codeSamples entry, selected when the page opens
Python SDKYour second entry
cURLGenerated
JavaScriptGenerated, with fetch
PythonGenerated, with requests

Your samples always come first, in the order you list them, so put the call you most want copied at the top. The panel's Copy button copies whichever tab is showing.

That last row is why the entries have labels. Without one, your Python entry would also be called Python, and the page would show two tabs with the same name. If you give two of your own entries the same label, Blume adds the language to tell them apart: two entries labeled SDK become SDK · TypeScript and SDK · Python.

Keep or drop the generated samples

The adapter's codeSamples option lists the generated languages, in order. It defaults to ["curl", "js", "python"]. For SDK tabs plus one raw HTTP request, keep only cURL:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  navigation: {
    tabs: [
      { label: "Docs", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [
    openapi({
      codeSamples: ["curl"],
      route: "/api",
      spec: "./openapi.yaml",
    }),
  ],
});

Now each operation shows TypeScript SDK, Python SDK, and cURL. Keeping a generated request is worth it: it's the one sample that follows the Try it form, and it serves readers who don't use your SDK. The OpenAPI reference lists every language id.

To show only your own samples, set codeSamples: false. An operation with no x-codeSamples then has no request samples at all, so add them to every operation before you turn the generated ones off. An empty list doesn't do the same thing: codeSamples: [] brings back the defaults.

The option covers every operation in that reference. You can't drop the generated samples for one operation only. If a second spec needs a different mix, give it its own openapi() adapter with its own route and codeSamples.

What changes when readers use Try it

The generated samples are built from the operation, so they change as a reader fills in the Try it form. A copied cURL command always matches what Send would do. Your samples are fixed text. They stay exactly as written when the form changes, so a reader who types a new address into Try it sees it in the cURL tab and not in the TypeScript SDK tab.

Two habits keep that from confusing anyone:

  • Use the spec's example values. The form starts from the spec's examples, so when your samples use the same values (here, ada@example.com, welcome, and msg_8f2k), every tab agrees when the page opens.
  • Read the key from the environment. Include my values in samples only fills in the generated tabs. Your samples show whatever you wrote, so write process.env.ACME_API_KEY or os.environ["ACME_API_KEY"], never a real key.

One more limit: samples live only on the rendered page. Site search doesn't index them, and the page's Markdown copy, which agents, the MCP server, and the assistant read, holds the endpoint and description but not the samples. If agents should learn your SDK, write a hand-written quickstart page with the same calls.

Test every sample against your SDK

A sample in a spec is a string, so nothing checks it. Pull the samples out into real files and type-check them against the SDK version your docs describe. Make a samples-check folder beside openapi.yaml, with its own dependencies so the SDK never becomes a dependency of the docs site.

First, a script that finds every x-codeSamples entry and writes each TypeScript and Python one to out/. It also fails on entries Blume would skip without a warning:

// Write every x-codeSamples entry in the given files (specs or overlays) to
// out/, one file per sample, so tsc and pyright can check them.
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { parse } from "yaml";

const EXTENSIONS = { py: "py", python: "py", ts: "ts", typescript: "ts" };
const samples = [];
const problems = [];

// Walk the whole document, so samples under paths, webhooks, and overlay
// actions are all found. Each is named for its operation when it has one.
const walk = (node, name) => {
  if (typeof node !== "object" || node === null) {
    return;
  }
  const here = node.operationId ?? name;
  for (const [key, value] of Object.entries(node)) {
    if (key !== "x-codeSamples" && key !== "x-code-samples") {
      walk(value, here);
    } else if (Array.isArray(value)) {
      samples.push(...value.map((sample) => ({ name: here, sample })));
    } else {
      problems.push(`${here}: ${key} must be a list`);
    }
  }
};

for (const file of process.argv.slice(2)) {
  walk(parse(await readFile(file, "utf8")), "sample");
}

await rm("out", { force: true, recursive: true });
await mkdir("out");

for (const [index, { name, sample }] of samples.entries()) {
  const { label = "", lang, source } = sample ?? {};
  // Blume skips an entry like this without a warning, so fail on it here.
  if (typeof lang !== "string" || typeof source !== "string" || typeof label !== "string") {
    problems.push(`${name}: sample ${index} needs a string lang and source`);
    continue;
  }
  const extension = EXTENSIONS[lang.toLowerCase()];
  if (extension) {
    await writeFile(`out/${name}_${index}.${extension}`, source);
  }
}

console.log(`Found ${samples.length} samples`);
if (problems.length > 0) {
  console.error(problems.join("\n"));
  process.exit(1);
}

Then the dependencies. Pin the SDK to the exact version your docs describe. The check script extracts the samples, runs tsc over the TypeScript ones, and runs pyright over the Python ones:

{
  "private": true,
  "scripts": {
    "check": "node extract.mjs ../openapi.yaml && tsc -p . && pyright out --pythonpath .venv/bin/python"
  },
  "devDependencies": {
    "@acme/messages": "1.4.0",
    "@types/node": "24.19.0",
    "pyright": "1.1.414",
    "typescript": "7.0.2",
    "yaml": "2.9.1"
  }
}
acme-messages==1.4.0

The TypeScript config treats every sample as a module, so top-level await works, and checks nothing but the extracted files:

{
  "compilerOptions": {
    "module": "esnext",
    "moduleDetection": "force",
    "moduleResolution": "bundler",
    "noEmit": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "es2022",
    "types": ["node"]
  },
  "include": ["out/**/*.ts"]
}

Install both SDKs and run the check:

cd samples-check
npm install
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
npm run check

A passing run prints the number of samples it found and pyright's 0 errors. Rename send to create in the TypeScript sample and tsc fails on out/sendMessage_0.ts, saying the property doesn't exist. Rename template in the Python sample and pyright fails on the missing and unknown arguments. Each file is named for its operation ID and the sample's position, so the error points at the spec entry to fix.

Type-checking proves each call matches the SDK's types at the pinned version: method names, argument names, and required fields. It doesn't send a request, and pyright catches the most in an SDK that ships type hints. Keep the generated files and the virtual environment out of Git:

samples-check/node_modules
samples-check/.venv
samples-check/out

Run npm run check in CI beside your docs build, so a pull request that breaks a sample fails. Keep API documentation in sync with backend changes in CI shows the workflow to add it to.

Keep samples in step with SDK releases

Nothing in Blume ties a sample to an SDK version. When you release an SDK that renames a method, the docs keep showing the old call until someone changes it. The pins in samples-check are how you notice: bump them to the new release in the same pull request as the sample updates, and the check fails on every call the release broke. If a dependency bot opens the bump for you, the failing check is the reminder.

When the spec is generated

If your spec comes from your code, regenerating it would wipe samples you wrote into it. Keep them in an overlay instead, a separate file Blume applies to the spec at build time:

overlay: 1.1.0
info:
  title: SDK code samples
  version: 1.0.0
actions:
  - target: $.paths['/messages'].post
    update:
      x-codeSamples:
        - lang: typescript
          label: TypeScript SDK
          source: |
            import { Acme } from "@acme/messages";

            const acme = new Acme({ apiKey: process.env.ACME_API_KEY });

            const message = await acme.messages.send({
              channel: "email",
              to: "ada@example.com",
              template: "welcome",
            });
            console.log(message.id);

List it under overlays in the adapter:

openapi({
  codeSamples: ["curl"],
  overlays: ["./overlays/code-samples.yaml"],
  route: "/api",
  spec: "./openapi.yaml",
}),

Then add ../overlays/code-samples.yaml after ../openapi.yaml in the check script, so the check reads both files. An overlay adds to any samples the operation already has rather than replacing them, so keep each sample in one place, or the page shows it twice. The overlays guide covers the format in depth.

When the SDK is generated

SDK generators can write the samples themselves, from the SDK they build, so the samples change with every release. Speakeasy writes them as an overlay file, which goes under overlays. Stainless publishes a copy of your spec with x-codeSamples added, and can serve it from a stable URL; point spec at that URL. With either one, the samples come from the generator, so the check above is optional.

Troubleshooting

A sample doesn't appear

Blume skips an entry without a warning when its lang or source isn't a string, or when x-codeSamples isn't a list. A common cause is a source written as a $ref to a file, which Redocly supports and Blume doesn't: paste the code inline. Also check the list sits on the operation, not on the path. Run node extract.mjs ../openapi.yaml in samples-check to name each entry that's wrong. If the spec is right, restart the dev server.

Two tabs are both called Python

Your entry has no label, so it took the language's name, the same as the generated Python tab. Give it a label, or drop python from codeSamples.

The generated samples are still there

codeSamples: [] means the defaults. Use codeSamples: false to turn them off, and check you edited the adapter whose route serves the page.

A sample has no syntax colors

The highlighter didn't recognize the lang, so the code shows as plain text. Use a common language name, like typescript, python, go, or bash.

The SDK tab ignores the Try it form

That's by design: only generated samples follow the form. Keep a generated cURL tab for readers who want a request built from their own values.

Next step

Check your samples in CI

Run the sample check beside your docs build, so a spec change or an SDK bump that breaks a sample fails the pull request.

Read the CI guide

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init