@buddy-works/docs-schema
Fetches Buddy's YAML and REST API schemas and resolves them into shapes you can
render or search. It follows $ref pointers, flattens allOf, and keeps enums,
constraints and examples.
Installation
npm install @buddy-works/docs-schema
Usage
import { BuddySchema } from "@buddy-works/docs-schema";
const parser = await BuddySchema.load();
await parser.actions.list(); // every action a pipeline can run
await parser.actions.get("AMAZON_S3"); // one action, in full
await parser.actions.search("notify"); // the ones that send notifications
await parser.search("deploy to s3"); // across YAML and the REST API
await parser.api.endpoint("GET", "/workspaces/{workspace_domain}/projects");
How it gets the schemas
BuddySchema.load() reads from a local cache and revalidates over HTTP with an
ETag once the cache is 3 hours old. A 304 keeps the cached copy and starts the
window again. A 200 replaces the document. Nothing is fetched until a call needs
it, so a process that only asks about actions never downloads the OpenAPI
document.
In a long-running process such as an MCP server, the check runs in the background and the call answers from memory. A query never waits on the network. When a 200 lands, the new document takes over and the shaped results derived from the old one are dropped. A query already in flight finishes against the graph it started with.
A response that does not parse, or that lacks the key its scope depends on, is
refused. The previous good copy stays in place rather than being overwritten by an
error page. If the server cannot be reached at all, the cached copy keeps being
served and stats() reports staleSince for that document.
It reads production unless environment says otherwise. The other names are
"stage", "master" and "dev". Each one caches separately, so a process can
hold two at once:
const live = await BuddySchema.load();
const stage = await BuddySchema.load({ environment: "stage" });
Supplying the documents yourself
If you already have the schemas, hand them over instead of letting the package fetch. This covers a pinned version, a file on disk, or a schema that is not published. Nothing is fetched and nothing is cached:
import { readFileSync } from "node:fs";
const parser = BuddySchema.from({
pipelines: JSON.parse(readFileSync("yaml-schema.json", "utf8")),
});
The keys are documents, not scopes, and pipelines backs two of them:
| Key | Scopes it serves |
|---|---|
pipelines | parser.pipelines, parser.actions |
targets | parser.targets |
environments | parser.environments |
sandboxes | parser.sandboxes |
agents | parser.agents |
domains | parser.domains |
customActions | parser.customActions |
distributions | parser.distributions |
api | parser.api |
Reaching a scope whose document you did not supply throws and names what was given.
Methods
Ten scopes over those nine documents. What a scope offers depends on what its
schema declares, and the types enforce it. parser.agents.list() does not
compile, because the agents schema has no union.
Every YAML scope
| Method | What it does |
|---|---|
names(options?) | Definition names in the document, minus the ones the schema retired. { includeDeprecated: true } lists those too.await parser.pipelines.names() → ["PipelineYaml", "TransferActionYaml", …] |
def(name) | One definition as a document: fields, types it refers to, examples.await parser.pipelines.def("VariableYaml") |
properties(name) | The same definition flattened into property rows, the shape a table renders. Empty for a name the document does not declare.await parser.pipelines.properties("VariableYaml") |
search(query, options?) | Ranked hits. The query is split into words and results rank by how many they match, so a whole question works.await parser.actions.search("notify") → ROLLBAR, SMS, HONEYBADGER…await parser.search("deploy to s3") → AMAZON_S3… |
search(query, { fuzzy: true }) | The same, tolerating typos. Off by default, because a loose match out-ranks the exact one.await parser.actions.search("varibles", { fuzzy: true }) → SET_VARIABLES… |
search(query, { includeDeprecated: true }) | Retired members and definitions too. Off by default, like list(): searching is how a caller finds what to use.await parser.actions.search("sandbox") → the live ones only |
Scopes with a root
pipelines, targets, environments, sandboxes, agents, domains,
customActions, distributions
root() gives the document's entry type: what a YAML file at that path may
contain. await parser.pipelines.root() returns the PipelineYaml document.
Scopes with a union
actions, targets, environments
| Method | What it does |
|---|---|
list(options?) | The discriminator values the union accepts, minus the ones the schema retired.await parser.targets.list() → ["SSH", "FTP", "MATCH", …]{ includeDeprecated: true } lists those too. get() and search() answer for a retired member either way. |
get(value) | One member in full: name, docs slug, tags, required fields, examples, properties, and the shaped document.await parser.actions.get("BUILD")outputs holds what an action leaves behind: files with a path and description, and variables with a name, description and example. Both lists are empty for a member that declares none.(await parser.actions.get("HTTP")).outputs.variables → BUDDY_HTTP_ACTION_RESPONSE_STATUS… |
commonFields() | Fields every member of the union declares.await parser.actions.commonFields() → ["type", "action", "trigger_time", …] |
pipelines
| Method | What it does |
|---|---|
eventVariables(type?) | The variables a run exposes, by the event that started it. Each has a name, description and example. Without a type, every event type that exposes any.await parser.pipelines.eventVariables("PUSH") → BUDDY_RUN_EVENT_TYPE…An event type that exposes none, such as SCHEDULE, gives an empty list. |
api
| Method | What it does |
|---|---|
operations() | Every operation the document declares, with its summary, operationId, tags, deprecated and security. Nothing is resolved, so listing every endpoint costs one walk rather than one per operation.await parser.api.operations() |
endpoint(method, path, variant?) | The operation as a document: parameters, request body, responses. Pass variant to pick one branch of a union body.await parser.api.endpoint("POST", "/oauth2/register")Bodies are a list: request.contents and each response's contents hold one entry per media type, in the document's order, so a download offering a byte stream and a JSON envelope reports both. A status that declares no content has none. |
variants(method, path) | The discriminator values you can pass as variant. Empty when the body branches by named type instead. Those shapes are on the document, under request.contents[0].body.variants. |
type(name) | One named schema as a document, for a type name you read off a field. Saves refetching the endpoint it came from.await parser.api.type("ExecutionView") |
typeNames(options?) | Every schema name the document declares, minus the retired ones. |
search(query, options?) | As above, over operation summaries, paths and tags. |
On the client
| Method | What it does |
|---|---|
search(query, options?) | One index across every scope, YAML and REST together. Narrow it with { scopes: ["actions"] }. Twenty hits unless limit says otherwise.Each hit carries kind, scope, key, name, tags, score. |
raw(document) | The unresolved document, $ref pointers intact. For a validator compiling against them, or for reading $defs directly. |
prefetch(documents?) | Load now rather than on the first request. Under from() this reports { refreshed: [], reason: "documents were supplied" } rather than throwing. |
release(document) | Drop a fetched document and its resolution memo. The shaped-result cache is shared, so it is emptied whole rather than per document. Returns false for a supplied document, which cannot be reacquired. |
stats() | Which documents are held, their ETag and fetch time, which deployment they came from, whether a write to the cache failed, and the result cache's entries, hits and misses. |
Options
await BuddySchema.load({ environment, cacheDir, ttlMs, results });
BuddySchema.from(documents, { results });
environment is one of the names above. cacheDir and ttlMs fall back to the
variables below. results bounds how many shaped documents are held. Pass false
to shape every call afresh.
Errors
DocumentUnavailableError: nothing fetched and no cached copy. Names the URL and points atBuddySchema.from().MissingDocumentError: a scope was reached whose documentfrom()was not given. Lists what was.ScopeUnsupportedError: a supplied document is not the schema that scope needs. Says what was expected, what was found, and the nearest existing name.
Validating a YAML file
@buddy-works/docs-schema/validate checks a YAML file against the schema of a
scope with a root, the same schema the methods above read. Any other scope throws.
import { validateYaml } from "@buddy-works/docs-schema/validate";
const report = await validateYaml(parser, "pipelines", yaml);
The report holds findings, checked and blocked. blocked is true only
when the file cannot be read as that scope's document at all. Every other finding
is advice, and what to refuse on is the caller's call.
Each finding has a severity, a message and a path such as
pipelines[0].actions[1].type, which together read as one line. For anything
else, use the rest:
-
at: the same location as segments,[0, "actions", 1, "type"]. A finding about a key points at the key. -
code: what went wrong, with the details beside it:codeDetails Meaning syntaxline?,column?The YAML does not parse, or an alias in it has no anchor or expands without end. Blocks. Aliases carry no position. documentexpected,gotNot the kind of file the scope takes, a list of entries or one entry. An empty file is neither. Blocks. emptyA list with no entries. shapeexpected,gotA value of the wrong kind, such as a word where an object belongs. missingTypekeyAn item that does not name its type. unknownTypekey,value,suggestion?A type the union does not have, with the nearest one it has when one is close. missingKeykeyA required key that is absent. emptyValuekeyA required key written with no value. A warning: Buddy saves it as an empty string. emptyItemA list item written with no value. Buddy refuses the file. unknownKeykey,suggestion?A key the schema does not declare. An error where the schema closes the object, a warning where it leaves it open, nothing where it checks such keys by a rule of its own (a value that breaks the rule is a schemafinding).schemakeyword,paramsAny other rule of the schema. -
declaredIn: on an unknown key, where the keys allowed there are declared, either an action type or a definition in a scope. Point a reader at it, such as withparser.actions.get(type)orparser.pipelines.def(name).
checked lists each entry, list item and object section that could be matched
to a definition and checked against it. An item missing its type, of a type the
union lacks, or of the wrong kind is not on it. Each one comes with that
definition (a plain value has none) and the variant a union chose. A node passed
when no finding's at starts with its own.
After Buddy saves a file, it may have dropped keys or renamed them.
reconcileWithSaved(findings, sentYaml, savedYaml) compares the file that was
sent with the file that was saved:
- It drops a finding about a key the saved file has, under that name or under another.
- It marks an unknown key that the saved file lost as
not saved by Buddy.
Requirements
Node 20.19 or newer, or Bun.
The cache lives in ~/.buddy/docs-schema, one pair of files per document: the
body and its ETag. Two environment variables override the defaults:
BUDDY_DOCS_SCHEMA_CACHE: cache directory.BUDDY_DOCS_SCHEMA_TTL: milliseconds a cached copy stays fresh.0revalidates on every access.Infinitypins a run to the version it started with.