YAML and JSON pipelines
Use compilePipelineDocument(document, registry) from tubeless/project to turn a parsed
YAML or JSON document and a registry of functions into ordinary pipelines. The
document owns step identities, dependencies, targets, and policies; application
code owns the handlers, schemas, and command parameters.
The project compiler remains dependency-free. It does not parse YAML,
import modules, or run application code. Use your runtime’s YAML support or a
parser of your choice. JSON works through JSON.parse without another package.
Validate quickly
Validate a document without importing any handlers:
tubeless validate pipelines.yaml
tubeless validate --json pipelines.yaml
In this repository, use make validate FILE=examples/declarative/peloton.yaml,
or bunx tubeless validate --json examples/declarative/peloton.yaml.
From a Tubeless checkout, bun run tubeless -- runs the local build.
Exit code 0 means valid structure; 4 means a
file, parse, or document validation failure; 1 means invalid command usage.
JSON output includes ok, the file path, pipeline IDs and metadata on success,
or a diagnostic with a document path when available.
This checks structure and metadata only. To check registered handler names,
dependency references, cycles, and targets, load the compiled command with
tubeless inspect or tubeless plan. Domain schemas run only during execution.
The CLI’s Bun YAML parser can overwrite duplicate mapping keys; choose a parser
with duplicate-key rejection when that check is required.
Downloadable JSON Schema
The pipeline document JSON Schema describes
version 1 using JSON Schema draft 2020-12. The website serves the same file at
https://tubeless.io/schemas/pipeline-document-v1.schema.json, with a current
alias at https://tubeless.io/pipeline-document.schema.json. It is also packaged
at docs/pipeline-document.schema.json. The website’s llms.txt links to the
versioned URL for agents. Validate parsed YAML against it using a JSON Schema
validator with date-format checking enabled.
For YAML language-server autocomplete and diagnostics, associate the schema:
# yaml-language-server: $schema=https://tubeless.io/schemas/pipeline-document-v1.schema.json
version: 1
metadata:
name: Race weekend
description: Prepare the riders and publish a validated start list.
authors: [Race operations]
date: "2026-09-17"
pipelines:
# Pipeline definitions go here.
JSON documents can use a top-level $schema string; YAML documents may include
it too. Tubeless accepts that association but never fetches it. version
selects the document format.
Document metadata
The optional metadata object supports name, description, authors (an
array of names or organizations), and date (a quoted YYYY-MM-DD calendar
date). All fields are optional. Dates and authors are maintained by the author;
they are not inferred from Git and do not schedule execution.
tubeless validate --json preserves and reports metadata. Document compilation
produces the same pipelines regardless of metadata.
The compiled value exposes validated, immutable metadata, including an immutable
authors snapshot. Reuse its name and description when registering a project:
import { compilePipelineDocument, defineProject } from "tubeless/project";
const compiled = compilePipelineDocument(document, registry);
const project = defineProject("data-jobs", compiled.pipelines, {
name: compiled.metadata?.name,
description: compiled.metadata?.description,
});
// Ordinary object construction controls overrides.
const production = defineProject("production", compiled.pipelines, {
...compiled.metadata,
name: "Production data jobs",
cwd: "./jobs",
});
Project IDs and execution cwd belong to registration. Project metadata is immutable;
project.name defaults to its ID when no name is supplied. Passing undefined
uses that default and leaves description absent; there is no implicit metadata inheritance.
Document authors and date remain available as descriptive data on compiled.metadata.
Each pipeline definition may separately declare name and description; compiled
commands inherit them. Top-level document metadata does not override pipeline labels,
IDs, options, or run timestamps.
For custom CLI inputs, build a command directly from compiled.get(id). It can
run independently or be registered directly in the project’s entry list:
import { definePipelineCommand } from "tubeless/cli";
const pipeline = compiled.get("yaml-import");
const command = definePipelineCommand(pipeline, {
params: { lines: { type: "string" } },
mapOptions: ({ lines }) => ({ lines: lines.split(",") }),
});
export default defineProject("yaml-jobs", [command]);
Only the selected pipeline is registered. Its compiled children still run through composition, without becoming separate project entries.
Try the example
From the repository root, use the registered examples:
bunx tubeless plan --project examples/project/tubeless.project.ts yaml-import --target normalize --explain
bunx tubeless run --project examples/project/tubeless.project.ts yaml-import -- --lines " Alpha , Beta , "
bunx tubeless ui examples/project/tubeless.project.ts
Studio lists Import rows from YAML and Preview rows from YAML. Their command parameters supply the input forms; the normal preview and run controls use the compiled pipelines. Browser launches record the same events as TypeScript-authored pipelines.
The loader and commands use Bun’s native YAML
imports. The document describes four
pipelines sharing an explicit handler registry.
Its import command uses fromPipeline, whose child uses forEachPipeline to
normalize each row. Both composed steps use registered application adapters,
and empty input is an intentional policy skip.
Edit the YAML and restart the command or Studio to load the new definition.
The CLI accepts the command module or registered ID, not a bare YAML file.
Peloton demo
For a larger graph, try YAML Peloton. Its seven-step document declares rider discovery, weather, kit normalization, bike inspection, a separate car audit, tech validation, and publication. The handlers simulate all work; no network requests or filesystem writes occur.
# Visible progress, concurrent bike inspections, and a simulated radio retry
bun examples/yaml-peloton.ts --delay 200 --concurrency 2
# Substitute the weather preview handler and skip publication
bun examples/yaml-peloton.ts --dry-run --delay 0
# Select validation without publishing or auditing cars
bun examples/yaml-peloton.ts --target validate-tech --delay 0
# An independent audit failure allows publication, but the run still fails
bun examples/yaml-peloton.ts --fail-audit --continue-on-error --delay 0
# Tech failure blocks publication even when independent work may continue
bun examples/yaml-peloton.ts --fail-tech --continue-on-error --delay 0
Both failure examples intentionally exit with an execution error. The finalizer returns a partial summary so dry runs and target selection do not require a published start list. Option and rider-output schemas validate the dynamic document boundaries.
Open make ui STUDIO=examples/project/tubeless.project.ts and select
Peloton from YAML to use the same demo in Studio. Its form exposes delay,
inspection concurrency, and both failure switches. Keep a nonzero delay to
watch progress or try cancellation. Plan without running any handlers:
make plan FILE=yaml-peloton PROJECT=examples/project/tubeless.project.ts ARGS="--target publish-start-list --explain"
The publication target selects the tech gate and its prerequisites, but omits
the independent audit. Per-rider inspection runs through runConcurrent inside
one ordinary step, with explicit progress details and retry events. This demo
chooses lightweight in-handler concurrency; the smaller YAML recipe shows
declarative child-pipeline composition and fan-out when each item needs its own
child lifecycle.
Document format
version: 1
pipelines:
import:
optionsSchema: lines
targets: [normalize]
steps:
- id: normalize
fromPipeline:
pipeline: normalize-all
adapter: allRows
skip: noRows
finalize:
run: normalizedRows
requireOutputs: [normalize]
normalize-all:
optionsSchema: lines
steps:
- id: normalize
forEachPipeline:
pipeline: normalize-one
adapter: eachRow
skip: noRows
finalize:
run: normalizedRows
requireOutputs: [normalize]
normalize-one:
optionsSchema: line
steps:
- id: normalize
run: normalizeLine
finalize:
run: normalizedLine
requireOutputs: [normalize]
Each key in pipelines is a pipeline ID. Step IDs are local to that pipeline;
registry names can be reused across pipelines. Each pipeline requires at least
one step and a finalizer. Step and pipeline forward references are allowed.
Dependencies determine execution order using the existing engine, not the
order in the file alone. Composition cycles are rejected.
| Field | Meaning |
|---|---|
Pipeline name, description |
Optional command and Studio presentation; the mapping key remains the stable ID |
optionsSchema |
Name in registry.optionsSchemas; validates/transforms domain options |
resultSchema |
Name in registry.schemas; validates/transforms the final result |
targets |
Public target step IDs, using normal dependency closure |
Step id, name, description |
Stable identity and optional presentation text |
Step run |
Ordinary function name in registry.steps |
Step fromPipeline.pipeline |
One child pipeline ID in this document |
Step fromPipeline.adapter |
Name in registry.fromPipelineAdapters |
Step forEachPipeline.pipeline |
Fan-out child pipeline ID in this document |
Step forEachPipeline.adapter |
Name in registry.forEachPipelineAdapters |
Step dependsOn |
Required dependency step IDs |
Step optionalDependsOn |
Optional dependency step IDs; optional-only steps are not pulled into target selection |
Step skipAfterFailureOf |
Failure gates, without providing dependency outputs |
Step skip |
Runtime predicate name in registry.skipPredicates |
Step dryRun |
skip, or for ordinary steps { run: previewHandler } from registry.steps |
Ordinary step outputSchema |
Name in registry.schemas; validates/transforms the published output |
finalize.run |
Function name in registry.finalizers |
finalize.requireOutputs |
Required output step IDs, compiled through requireOutputs |
Every step declares exactly one of run, fromPipeline, or
forEachPipeline. Child references resolve only pipelines in the same
document. The adapter is application code: a fromPipeline adapter supplies
mapOptions and optional controls and mapResult; a forEachPipeline adapter supplies
items, key, mapOptions, and optional controls, concurrency, progress, and
mapResult. These are the same hooks as the TypeScript builders, so a static
adapter concurrency must be a positive integer and is rejected at the
document reference. Parent plans
still expose one opaque wrapper with nestedPipeline metadata, and execution
retains the normal nested progress and failure behavior.
Omitted dryRun runs the normal handler or child workflow during a dry run.
Mark writes with dryRun: skip; ordinary steps can instead supply a
side-effect-free preview handler. A skipped required output can prevent
finalization. A runtime skip is a successful policy skip, can publish a value,
and unlocks dependents. The compiler preserves the engine’s existing semantics.
See core concepts.
Compile and select pipelines
import { compilePipelineDocument } from "tubeless/project";
// `document` is the unknown result of your YAML or JSON parser.
// `registry` explicitly imports and registers your application functions.
const compiled = compilePipelineDocument(document, registry);
const pipeline = compiled.get("import");
const plan = pipeline.plan({ targets: ["normalize"] });
const result = await pipeline.runOrThrow({ lines: [" Alpha ", "Beta"] });
Step handlers receive (inputs, context). Required and available optional
dependency outputs are keyed by step ID, e.g. inputs.load. Domain options
live in context.options. Use the normal context.log, context.signal,
context.sleep, and progress APIs. Finalizers receive (outputs, context);
without requireOutputs, they must handle absent outputs themselves.
compilePipelineDocument validates the document and compiles its pipelines. It returns
an immutable CompiledPipelineDocument with a readonly pipelines collection,
get(id), and optional PipelineDocumentMetadata. Lookup throws a clear error for
an unknown ID. Repeated lookup, collection entries, and references from parent
pipelines share one compiled instance per pipeline ID. Compilation does not register
a project; defineProject collects whichever existing pipelines you choose.
The registry has
separate steps and finalizers function maps, plus optional
skipPredicates, fromPipelineAdapters, forEachPipelineAdapters,
optionsSchemas, and schemas maps. Schemas use Standard Schema v1.
optionsSchemas must accept and produce objects, matching Tubeless domain
options. schemas can validate any step-output or final-result shape. Functions
are resolved once during compilation; modifying a registry later does not
replace handlers in an already compiled pipeline.
Skip predicates receive (inputs, context) and return the normal
StepSkipDecision. A single-child adapter’s mapOptions and dynamic controls
receive the same arguments. A fan-out adapter receives the normal items, key,
mapOptions, controls, and result-mapping arguments documented for
forEachPipeline. Keep stable and
unique keys, and return the complete parent-facing result array from a valued
fan-out skip.
YAML cannot provide TypeScript’s inferred graph wiring. Handler inputs and
pipeline results are unknown, handler options are object, and pipeline IDs
are string. Narrow them
or validate at the application boundary. Schemas validate runtime values; the
compiler does not prove compatibility between two schemas.
User decisions, confirmations, and text
Declarative pipelines can wait for user input through registered async run
handlers. Use waitForInput(...).run from createSteps to give those handlers
the same waiting progress and cancellation behavior as TypeScript input steps.
The application supplies a terminal, browser form, or other input adapter.
| Question | Adapter reply | Output validation |
|---|---|---|
| Select a/b/c from a list | A stable choice value, such as "b" |
An allowed-value schema |
| Yes/no or “confirm proceeding?” | true or false |
A boolean schema |
| Open text | A string | A string schema with any application constraints |
Version 1 uses ordinary run references; it has no YAML waitForInput,
question, or choices fields. Messages, choices, input normalization, and UI
rendering belong to the registered handler and its adapter. CLI and Studio do
not automatically render these questions.
For example, a confirmation can refer to an upstream result:
- id: approval
run: confirmProceeding
dependsOn: [output]
outputSchema: yesNo
dryRun: skip
- id: proceed
run: proceed
dependsOn: [output, approval]
skip: declined
dryRun: skip
Register the input handler, output schema, and decline predicate:
import { createSteps } from "tubeless";
import type { ProjectRegistry } from "tubeless/project";
const { waitForInput } = createSteps<object>();
const registry: ProjectRegistry = {
steps: {
prepareOutput: () => "xyz",
confirmProceeding: waitForInput("approval", {
read: ({ output }: Record<string, unknown>, context) => {
if (typeof output !== "string") throw new Error("Expected output");
return readFromYourUI(`Output was ${output}. Confirm proceeding?`, context.signal);
},
}).run,
proceed: ({ output }) => output,
},
schemas: {
yesNo: {
"~standard": {
version: 1,
vendor: "example",
validate: (value) =>
typeof value === "boolean"
? { value }
: { issues: [{ message: "Expected a boolean confirmation" }] },
},
},
},
skipPredicates: {
declined: ({ approval }) => (approval === false ? "User declined" : false),
},
finalizers: {},
};
The UI adapter should return an actual boolean, converting its yes/no text or
button selection as needed. A false confirmation is a valid answer: the
predicate skips proceed and the pipeline can finish successfully. Invalid
answers fail output validation before dependent work starts. To keep asking
after invalid input, put that retry behavior in the adapter.
The YAML compiler registers the helper’s .run callback only. Declare
dependsOn, outputSchema, and dryRun in the document; the helper’s default
dry-run skip does not carry over. Use dryRun: skip to avoid prompting, or
dryRun: { run: previewApproval } with a registered preview handler. If skipped
input leaves outputs absent, choose a finalizer that handles that absence, or
require those outputs when a missing answer must prevent finalization.
While input is pending the run stays active. Forward context.signal to the
adapter so cancellation releases its UI/I/O resources; a late reply is ignored.
The application owns persistence and recovery across process restarts.
The complete YAML input recipe and
document demonstrate all three
question types, confirmation of xyz, declined execution, and a finalizer that
distinguishes an unanswered question from a deliberate false.
Validation and limits
Parsing errors belong to the YAML parser. Configure duplicate-key rejection when available: a compiler cannot recover keys that a parser already overwrote. Bun’s native YAML loader does not provide this guarantee. For strict authoring, use a parser configured to reject duplicate mapping keys before compilation.
Malformed documents, unknown fields, and unresolved names throw
PipelineDocumentError, with code TUBELESS_DOCUMENT_INVALID and a document
path. Source line/column locations are not retained by this compiler.
Duplicate step IDs, cycles, conflicting edges, and invalid target/finalizer
combinations go through core and throw the existing PipelineDefinitionError.
Compilation and plan() never invoke handlers or schemas. Business validation
happens during run(). Loading an application registry can still execute module
initialization code. Registry functions remain trusted application code.
Version 1 supports ordinary steps, single-child composition, child fan-out, and
runtime skip predicates. Remote steps, expressions, inline code, and external
pipeline references are not document features. Use TypeScript authoring for
those workflows. Register selected compiled pipelines with defineProject and
export that project from a project module for
CLI and Studio loading. Pipelines whose options schemas expose Standard JSON
Schema input metadata receive automatic commands. Custom or schema-less inputs
need definePipelineCommand adapters registered directly in the project’s entry list.
A YAML file alone does not grant execution access or define a new Studio protocol.
Graph metadata on individual pipelines and steps is currently a TypeScript API feature. Document version 1 rejects those fields; its document-level metadata remains unchanged. See graph metadata compatibility.