Skip to content

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.

- name: triage
executor: pipeline
executor_config:
pipeline: shared-triage # must match `name:` in shared-triage.yaml
confidence_threshold: 0.75
on_low_confidence: escalate

shared-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.

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.

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.

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.

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.

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: escalate

Branches 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.

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.