Chaining pipelines
executor: pipeline calls another named pipeline as a sub-pipeline: it
runs through the standard runner exactly like a top-level trigger would —
full step execution, its own DB row, its own trace — and its final step’s
output becomes the calling step’s output. This turns pipelines into
composable building blocks: a shared triage phase reused across a dozen
alert pipelines, a multi-stage workflow whose phases are independently
testable, or a fan-out where each branch delegates to a specialist
sub-pipeline instead of one generic prompt trying to handle every case.
Minimal setup
Section titled “Minimal setup”- name: triage executor: pipeline executor_config: pipeline: shared-triage # must match `name:` in shared-triage.yaml confidence_threshold: 0.75 on_low_confidence: escalateshared-triage needs to be its own YAML file, loaded the same way any
other pipeline is (pipeline_config_dir), and present in the registry at
call time. prompt_template is unused for this executor — leave it empty
or omit it; executor_config.pipeline drives everything.
Context inheritance
Section titled “Context inheritance”The sub-pipeline inherits the parent’s entire NormalisedContext by
default — severity, summary, labels, metadata, team, all of it —
with two fields VectorStep changes for you: pipeline (set to the
sub-pipeline’s own name) and source (set to "sub-pipeline", so a
sub-pipeline run is identifiable as such in its own right). fingerprint
is cleared, which bypasses dedup for the sub-pipeline’s own run.
Use context: to override or extend what it sees:
- name: triage executor: pipeline executor_config: pipeline: shared-triage context: summary: "{{ steps.pre_filter.next_step_context }}" # scalar — Jinja2-rendered labels: routed_by: "{{ pipeline_name }}" # dict — MERGED with parent labels metadata: focus: "Check database connection pool first"Scalar fields are replaced outright; labels and metadata are merged,
not replaced — the parent’s existing keys survive, and your override adds
or replaces individual keys on top. team follows the same
inherit-unless-overridden rule as any other field
(context: {team: "..."}), which matters for cost
attribution: a shared sub-pipeline’s token spend
rolls up to whichever team’s call triggered it, not to some fixed owner of
the sub-pipeline itself.
Reading the result back
Section titled “Reading the result back”The sub-pipeline’s final step’s LLMOutput becomes the parent step’s
output, so everything downstream can reference it exactly like any other
step:
- name: auto-remediation when: "steps.triage.action == 'remediate'" # a field the sub-pipeline's own JSON returned executor: gateway prompt_template: | Triage summary (sub-pipeline run {{steps.triage.sub_run_id}}): {{steps.triage.next_step_context}} Verdict: {{steps.triage.summary}}Two extra fields are always added on top of whatever the sub-pipeline’s
final step returned: sub_run_id (the run_id assigned to the
sub-pipeline’s own run) and sub_pipeline_status (its terminal status —
completed, failed, escalated, etc.), both available downstream the
same way, e.g. {{steps.triage.sub_pipeline_status}}. This is what makes
conditional routing off a sub-pipeline’s outcome straightforward — a
when: clause can key off either a genuine field from the sub-pipeline’s
own JSON output (like action above) or off sub_pipeline_status itself.
Traceability
Section titled “Traceability”The sub-pipeline runs with its own run_id, stored in pipeline_runs with
parent_run_id set to the parent run’s ID — fully queryable:
SELECT * FROM pipeline_runs WHERE parent_run_id = '<parent-run-id>';Both runs are independently visible in VectorStep’s own UI too — the
parent step’s expanded detail shows sub_run_id/sub_pipeline_status as
ordinary output fields, and the sub-pipeline’s own run is a completely
normal entry in the runs list, browsable and traceable on its own.
Three things that don’t show up until you go looking
Section titled “Three things that don’t show up until you go looking”The sub-pipeline’s own trigger: is never consulted
Section titled “The sub-pipeline’s own trigger: is never consulted”A sub-pipeline call is a direct registry lookup by name
(executor_config.pipeline) — it does not go through the same
match/resolve logic real webhook traffic does. This means the
sub-pipeline’s own trigger.match block is completely irrelevant to how a
sub-pipeline call routes; it only matters if that same pipeline is also
meant to be triggered directly by a real webhook. A pipeline that exists
purely to be called as a sub-pipeline still needs a syntactically valid
trigger: block (it’s a required field), but its match conditions can be
deliberately unreachable — or, if you want the same pipeline triggerable
both ways, write a trigger.match that reflects the direct-trigger case
and know that it’s simply skipped on the sub-pipeline path.
Token/cost accounting only sees the sub-pipeline’s last step
Section titled “Token/cost accounting only sees the sub-pipeline’s last step”The parent step’s output is the sub-pipeline’s final step’s LLMOutput
— including that one step’s own raw_response, which is exactly what
VectorStep’s cost/token accounting reads to price and count the parent
step. If the sub-pipeline has multiple internal steps, each with its own
LLM call, only the last one’s tokens are visible to the parent step’s own
accounting — not a sum across the whole sub-pipeline. The sub-pipeline’s
own pipeline_steps rows are complete and correctly priced in its own
right (nothing is lost from its own DB history or its own contribution to
cost accounting rollups), but a
parent’s budget.max_tokens/max_usd accumulator — and anything reading
the parent step’s row directly — will undercount a multi-step sub-pipeline
down to just its final step. Worth knowing before you rely on a parent
pipeline’s budget guardrail to catch a runaway sub-pipeline; check the
sub-pipeline’s own history, or give it its own budget: block, rather
than trusting the parent’s to see all of it.
The sub-pipeline’s stage is independent of the parent’s
Section titled “The sub-pipeline’s stage is independent of the parent’s”stage: testing/production is evaluated per pipeline, and a
sub-pipeline call runs through PipelineRunner.run() directly — it
completely bypasses the trigger-gating layer that stops a stage: testing
pipeline from firing on real /webhook traffic. A sub-pipeline call
always executes, regardless of either pipeline’s stage. What its own
stage does still control is what testing mutes
for that sub-pipeline’s own run (notifications forced to log,
executor: notify skipped, etc.) and whether its run counts toward
production metrics/rollups — both keyed off the sub-pipeline’s own
stage: field, independent of what the parent’s is. A stage: production
parent calling a sub-pipeline that’s still stage: testing gets a real
result back (the call itself isn’t gated), but that sub-call’s own
notifications stay muted and its run stays invisible to every aggregate
metric — easy to mistake for a bug in the parent when the actual mismatch
is one line in the sub-pipeline’s own file.
Failure behaviour
Section titled “Failure behaviour”If the sub-pipeline produced a final_output (its last step ran and
returned something) — that output is used as-is, regardless of the
sub-pipeline’s terminal status. An escalated sub-pipeline’s last output
still flows through; the parent step decides what to do with a low
confidence number the normal way, via its own confidence_threshold.
If the sub-pipeline has no final_output at all — aborted or
escalated before any step completed, or every step was skipped by when:
— the parent step synthesises a placeholder: confidence=1.0 if the
sub-pipeline’s status was completed, confidence=0.0 for anything else.
The 0.0 case reads correctly as “don’t trust this” to the parent’s gate.
The 1.0 case is the one worth pausing on: a sub-pipeline whose every step
happened to be skipped (a when: condition that never matched) still
counts as completed with nothing to actually check — and the parent
sees full confidence in the emptiness. If a sub-pipeline could plausibly
complete with zero steps run, that’s a distinct outcome from “it ran and
was confident,” and this fallback doesn’t distinguish them.
In a fan-out
Section titled “In a fan-out”Each branch can delegate to a sub-pipeline, passing the branch’s own item
via context: exactly like a sequential step would:
- fan_out: name: per-service-triage over: "{{ steps.identify_services.services }}" as: service executor: pipeline executor_config: pipeline: shared-triage context: labels: service: "{{ service }}" metadata: focus: "Focus specifically on {{ service }}" join: all_must_pass confidence_threshold: 0.75 on_low_confidence: escalateBranches run concurrently the same way any fan-out does — each one is its own full sub-pipeline execution, its own DB row, linked back to the same parent run.
Hot reload
Section titled “Hot reload”POST /reload/SIGHUP updates the pipeline registry, so editing a
sub-pipeline’s YAML takes effect immediately, the same as any other
pipeline change — no restart needed.
See samples/pipelines/sub-pipeline-example.yaml for a complete worked
example including conditional routing based on sub-pipeline output.
Where next
Section titled “Where next”- Executors
— the full
executor_configreference this guide walks through. - Parallel groups & fan-out — the mechanics behind the fan-out example above.
- Testing vs production stages — the full
reference for what
stage:gates, relevant to the stage-independence gotcha above. - Cost accounting — how token/cost rollups work, relevant to the accounting gotcha above.