API reference
Remove internal endpoints from public API docs
Publish a public API reference from the spec your code generates, with an overlay that strips internal operations on every build.
By Hayden Bleasel7 min read

By the end of this guide, your public API reference is built from the spec your code generates, with every internal operation stripped out on every build by a separate overlay file. The spec itself stays untouched, so your generator can keep overwriting it, and a one-line check proves nothing internal reached the published site.
It builds on Generate API docs from an OpenAPI spec, which sets up a reference for the Acme Messages API at /api. If you already have an OpenAPI reference in Blume, you can follow along with your own spec.
The spec you can't edit
Acme's openapi.yaml is generated from the API's code on every release. Along with the public operations, it includes one the support team uses to resend a failed message. The generator marks it with an x-internal flag:
openapi: 3.1.0
info:
title: Acme Messages API
version: 1.0.0
servers:
- url: https://api.acme.example/v1
tags:
- name: Messages
- name: Templates
- name: Internal
description: Operations the support team uses.
paths:
/messages:
post:
operationId: sendMessage
tags: [Messages]
summary: Send a message
# ...
/messages/{id}:
get:
operationId: getMessage
tags: [Messages]
summary: Get a message
description: >-
Returns a message and its delivery status. Replay a failed
message with POST /internal/messages/replay.
# ...
/templates:
get:
operationId: listTemplates
tags: [Templates]
summary: List templates
# ...
/internal/messages/replay:
post:
operationId: replayMessage
tags: [Internal]
summary: Replay a failed message
x-internal: true
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ReplayRequest"
responses:
"202":
description: Replay queued.
components:
schemas:
ReplayRequest:
type: object
properties:
messageId:
type: stringBlume doesn't treat x-internal specially. Left alone, the replay operation publishes like any other: a page at /api/internal/replay-message, a section on the overview, a search result, a Markdown copy, and an entry in llms.txt. Editing the spec by hand doesn't help, because the next release overwrites it.
What an overlay is
An OpenAPI Overlay is a separate YAML or JSON file that lists changes to make to a spec. Each change is an action with a target, a JSONPath expression that selects nodes in the spec, and one of three operations:
remove: truedeletes every node the target selects.updatemerges a value into every node it selects. Objects merge key by key, arrays gain the new items, and strings, numbers, and booleans are replaced.copymerges in a node from elsewhere in the spec. It's new in Overlay 1.1.
Blume supports Overlay 1.0 and 1.1. It applies each overlay to the spec as you wrote it, before upgrading the spec to OpenAPI 3.1, so your targets follow the version your spec is in. The spec file on disk never changes.
Write the overlay
Create overlays/public.yaml beside your spec, with one action that removes every internal operation and one that fixes a description:
overlay: 1.1.0
info:
title: Public Acme Messages docs
version: 1.0.0
actions:
- target: $.paths.*[?@['x-internal'] == true]
description: Remove every operation marked internal.
remove: true
- target: $.paths['/messages/{id}'].get
description: Stop pointing readers at the replay endpoint.
update:
description: Returns a message and its delivery status.Read the first target from left to right:
$.pathsis the spec's paths object..*selects every path item in it, like/internal/messages/replay.[?@['x-internal'] == true]keeps the children of each path item whosex-internalistrue. Those children are the operations, so the action removes thepostunder the replay path and nothing else.
The key uses bracket notation because x-internal contains a hyphen. The second action rewrites one description: the generated text for getMessage sends public readers to an endpoint they can no longer see, and since a description is a string, update replaces it.
Apply it
List the overlay beside the spec in your reference's config:
import { defineConfig } from "blume";
import { openapi } from "blume/reference";
export default defineConfig({
reference: [
openapi({
spec: "./openapi.yaml",
route: "/api",
overlays: ["./overlays/public.yaml"],
}),
],
});Each overlay is a local path, resolved from your project root like the spec, or an http(s) URL. With more than one, Blume applies them in the order you list them, and each sees the result of the one before. If you publish several specs through sources, give each source its own overlays instead.
Run npx blume dev and open /api. The Internal section is gone from the overview, since a tag with no operations left gets no section, and /api/internal/replay-message is a 404. The Get a message page shows the new description.
Check what you published
Everything Blume builds from the spec sees the overlaid version: the operation pages, the sidebar, search, the Markdown copies, llms.txt and llms-full.txt, the JSON documents under /api/docs/, and the MCP server if it's on. Blume never serves your spec file itself. The /openapi.json Blume publishes by default describes its own JSON docs API, not your API.
You don't have to check each surface by hand. Build the site and search the output for anything internal:
npx blume build
grep -rli "/internal/" dist/An empty result means no page, Markdown copy, search index, or text file mentions an internal path. This works because every internal operation here shares the /internal/ prefix, and so does the page route Blume would have given it. Search for whatever your internal operations have in common. On a server build, search dist/client/, which is the part your host serves.
To keep it that way, run the same check in CI after every build, so a leak fails the build instead of shipping:
npx blume build
if grep -rqi "/internal/" dist/; then
echo "An internal endpoint reached the public docs." >&2
exit 1
fiCatch the next internal endpoint
A month later, the generator adds an operation for purging a template. It carries the same flag:
/internal/templates/{id}:
delete:
operationId: purgeTemplate
tags: [Internal]
summary: Purge a template
x-internal: trueRebuild, and it's gone from the docs without a change to the overlay. That's the point of targeting the flag rather than the path. A target like $.paths['/internal/messages/replay'].post removes exactly one operation, and the new one would have published.
You might want a rule like "anything under /internal/" instead. JSONPath can't express that: its filters test the values inside a node, not the names of the keys that hold them, so no target can match a path by its URL. Agree on a marker inside the operation with whoever owns the generator. A flag like x-internal works, and so does a tag:
- target: $.paths.*[?@.tags[?@ == 'Internal']]
description: Remove every operation tagged Internal.
remove: trueClean up what's left behind
An overlay removes exactly what its targets select and nothing more. After the first action runs, these are still in the spec:
- The path items themselves, now empty:
/internal/messages/replay: {}. - The Internal tag and its description in the top-level tags list.
- Schemas only the internal operations used, like
ReplayRequest. - Prose in public operations that mentions an internal one, which the second action already fixed for
getMessage.
Blume's own reference shows none of the first three: a path with no operations gets no page, a tag with no operations gets no section, and a schema only renders where an operation uses it. They're still in the document Blume builds from, though, and a Scalar embed puts that whole document in the page. Remove them too:
overlay: 1.1.0
info:
title: Public Acme Messages docs
version: 1.0.0
actions:
- target: $.paths.*[?@['x-internal'] == true]
description: Remove every operation marked internal.
remove: true
- target: $.paths[?length(@) == 0]
description: Remove paths left with no operations.
remove: true
- target: $.tags[?@.name == 'Internal']
description: Remove the Internal tag.
remove: true
- target: $.components.schemas.ReplayRequest
description: Remove the schema only the replay operation used.
remove: true
- target: $.paths['/messages/{id}'].get
description: Stop pointing readers at the replay endpoint.
update:
description: Returns a message and its delivery status.Order matters here: the empty-path action only finds paths the first action emptied. A path item that also declares shared parameters isn't empty after its operations go, so target it by name. Schemas have no flag to filter on, so list each one an internal operation adds.
Keep the full spec private
An overlay changes what the docs show, not who can read the spec or call the API.
- Keep the spec out of
public/. Blume serves every file there as it is, at the site root, sopublic/openapi.yamlwould publish the full spec at/openapi.yamlwith every internal operation in it. Apublic/openapi.jsonalso takes over the/openapi.jsonroute from Blume's docs API description. Keep the spec at the project root or in a folder likespecs/. - A spec at a public URL is already public. If
specpoints at anhttps://URL anyone can fetch, the overlay hides operations from the docs, but the spec still lists them at that URL. - Docs aren't access control. Removing an operation from the docs doesn't stop anyone from calling it. Protect internal endpoints in the API itself, with authentication or network rules.
Troubleshooting
The internal operation still shows
A target that matches nothing changes nothing, silently, so the overlay applied but skipped the operation. Check three things in the spec: that x-internal sits on the operation and not on the path item above it, that it's the boolean true and not the string "true", and that the key is spelled the way your target spells it.
Targets that work on one spec miss on another
Overlays apply before Blume upgrades a spec to OpenAPI 3.1, so a target has to follow the spec as written. A Swagger 2.0 spec keeps its schemas under definitions, not components.schemas.
The whole reference disappeared
If an overlay can't be applied, Blume skips the reference rather than publish it without the overlay, since the overlay is often what hides the internal operations. blume build fails with BLUME_OPENAPI_UNAVAILABLE, and blume dev shows the same message as a warning. It names the overlay file and the action that failed. Common causes:
overlaymust be an Overlay Specification version, 1.0.x or 1.1.x: the file is missing its version line, or uses another version.- An overlay also needs
infowith atitleandversion, and at least one action. can't merge an object into a primitive value: anupdatetargets a string, like a description, with an object. Target the node that holds the string instead, as the second action does.
The config rejects overlays
overlays sits beside spec. With sources, Blume stops with "overlays belongs to the spec shorthand; with sources, give each source its own overlays." Move the list into each source.
Next step
Write your first overlay
Add an overlay beside your spec and list it in the reference's config.
Read the overlays docsA step here not working for you? Report a broken step.