Skip to content
agentic-pipelines

pipeline.yaml reference

Each pipeline lives in pipelines/<name>/pipeline.yaml inside the data repo. The engine validates the whole file with Zod in strict mode: at the top level, in every step, guard, always entry, mcp_servers entry, notify and defaults, an unknown key is an error, not something silently dropped.

Top-level fields

FieldTypeRequiredNotes
namestringyeskebab-case (^[a-z0-9][a-z0-9-]*$). Must match exactly the name of the directory that contains it; if it doesn't, validate and run fail explicitly. Locks, throttle and run history are keyed by this name.
descriptionstringyesNon-empty.
versionnumberyesInteger ≥ 1. Persisted in every run record (pipelineVersion) so a resume knows which version the original attempt ran against.
paramsmapnoSee params.
requiresobjectnoSee requires.
mcp_serversmapnoSee mcp_servers.
whenlist of guardsnoSee when.
triggerslistnoSee triggers.
defaultsobjectnoSee defaults.
notifyobjectnoSee notify.
stepslistyes (at least 1)See steps.
alwayslistnoSee always.

params

Declared params are the only way to parameterize a pipeline. Any other variable value must go through here, never hardcoded in run: or a prompt.

yaml
params:  docs_repo:    description: Local path of the clone the pipeline works on.    required: true  admin_repo:    description: Read-only path to the source-of-truth repo.    required: true  deploy_dispatch_url:    description: workflow_dispatch URL. If omitted, the deploy step is skipped.
Param fieldTypeRequiredNotes
descriptionstringyes
requiredbooleannoIf true and there's no value after resolving, run fails with a list of the missing params.
defaultstring, number or booleannoAlways normalized to a string when resolved.

Precedence: --set key=value on the command line, then .params.local.json (written by pipelines install --set; it can also be edited by hand), then the YAML default. Params are referenced as {{params.docs_repo}}.

requires

What the machine running the pipeline must have. pipelines doctor <name> checks each entry, and run won't proceed if something blocking is missing.

yaml
requires:  agents: [docs-writer, docs-reviewer]  bin: [git, jq, yarn]  env:    - DOCS_ADMIN_URL    - DEPLOY_TOKEN  net: [git.example.com]  skills: []
FieldTypeCheck in doctor
agentsstring listagents/<name>.md exists in the data repo.
skillsstring listThe skill is available in the user's Claude Code environment.
binstring listThe binary is on the PATH.
envstring listSecret names, never values (see Secrets). They accept {{params.*}} (see below). doctor requires a minimum length of 8 characters, an entropy heuristic that doesn't validate the content.
netstring listThe host resolves via DNS.

Secret names that depend on params

A generic pipeline can declare secrets whose name depends on its params:

yaml
params:  app: { description: App to test, required: true }  environment: { description: Environment, default: prod }requires:  env:    - QA_{{params.app}}_{{params.environment}}_USER    - QA_{{params.app}}_{{params.environment}}_PASSWORD

With --set app=myapp, the engine looks up QA_myapp_prod_USER in .env, checks it in the preflight, passes it to shell steps under that name and redacts it in the logs like any other secret. The rules:

  • Only references to declared params are allowed; validate rejects {{secrets.*}} and {{step.field}} in these names.
  • The resulting name must be a valid variable name (letters, digits and _, not starting with a digit). A my-app or 9app param produces an invalid name, and the preflight rejects it.
  • pipelines doctor accepts --set to resolve them, and it also reads .params.local.json, so an installed pipeline resolves without --set. A name it can't resolve shows as a red check that says which param is missing.
  • A templated name can't be referenced with {{secrets.X}} from the prompt of an agent step: that reference needs the literal name.

mcp_servers

The pipeline's registry of stdio MCP servers that an agent step can enable. Stdio only (no SSE or HTTP yet).

yaml
mcp_servers:  playwright:    command: npx    args: ["-y", "@playwright/mcp@0.0.79", "--headless", "--no-sandbox"]    kind: playwright
FieldTypeRequiredNotes
map keystring—Server name, kebab-case. It becomes part of the mcp__<name>__<tool> prefix you write by hand in a step's tools:.
commandstringyesInterpolable with {{params.*}}, never {{secrets.*}}.
argsstring listno ([])Interpolable like command.
envstring listno ([])Names of the secrets this server needs, resolved against requires.env.
kind"playwright" or "declared"noWithout kind:, no tool from that server passes validate: tool governance is deny by default. playwright uses the engine's own profile table; declared is for a server of your own whose tools the engine doesn't know, listed by you in allow.
allowstring listwith kind: declared (forbidden otherwise)The server's tools, without the mcp__<name>__ prefix, that a step may ask for. It's the server's ceiling: each step still grants itself the ones it uses in its tools:. validate rejects the always-forbidden execution tools (Bash, REPL, browser_evaluate…) by name; anything else, such as a tool that runs a shell under another name, is on the pipeline's author.

A declared server looks like this:

yaml
mcp_servers:  my-device:    command: my-device-mcp    args: ["--stdio"]    kind: declared    allow: [screenshot, tap]

and a step uses it with mcp_servers: [my-device] and tools: [mcp__my-device__screenshot, mcp__my-device__tap]. Because doctor and the preflight of every run start the server to list its tools, a declared server must answer tools/list without external resources, without secrets and with its {{params.*}} arguments unresolved.

A step enables a server by listing it in its own mcp_servers: [name]. The engine always starts with strictMcpConfig: true, so no MCP server already configured on the machine leaks into the session.

when (start guards)

A list of pipeline-level guards. All of them must pass for the run to start; if any fails, the run is recorded as skipped (not as a failure) and pipelines run exits with code 0.

yaml
when:  - throttle: 46h  - shell: 'git -C {{params.docs_repo}} diff --quiet'  - changed:      path: '{{params.docs_repo}}'      since: last_success  - between: '08:00-20:00'

Each entry declares exactly one of these four keys:

GuardShapePasses when
throttle"46h", or { every: "46h", since: last_success | last_attempt }every has elapsed since the reference point. since defaults to last_success; last_attempt is the last run that executed something (success or failed). Skipped runs don't count, so a pipeline can't push its own anchor forward with its own skips and never come due. Durations: \d+(s|m|h).
changed{ path: string, since: last_success | last_attempt }git log --since=<reference> finds a commit in path since the reference run. Same since as throttle.
shellstringThe command exits with code 0. It runs with sh -c on macOS and bash --posix -c on Linux, interpolated in a shell-safe way (see Interpolation).
between"HH:MM-HH:MM"The current time falls inside the window. Windows that cross midnight work, e.g. 22:00-06:00.

triggers

yaml
triggers:  - cron: "0 3 * * *"

The engine doesn't include a scheduler. pipelines install translates this field into launchd on macOS or systemd on Linux and leaves the job loaded; it's the single source of the cadence. The translation supports *, fixed values and lists (0,30); ranges (1-5) and steps (*/15) are rejected with an explicit error. See Scheduling.

defaults

Values every step inherits unless it declares them.

yaml
defaults:  on_error: stop  retry: 0  timeout: 15m
FieldTypeDefault
on_errorstop | continuestop
retrynumber of attempts, or { attempts: number, on: transient | any }0
timeoutduration (\d+(s|m|h))15m

retry.on: transient, the default when on is omitted, only retries errors the engine classifies as transient (rate limit, overload, server error, network timeout); any retries any failure.

notify

yaml
notify:  on: [success, failed]  stale_after: 72h  channel: email
FieldTypeNotes
onlist of success | failed | skippedEvery path that closes a run notifies, including a skipped guard and a busy lock.
stale_afterdurationOptional. Notifies when the pipeline has gone that long without a single successful run. Required by lint rule R4 when the pipeline has triggers and notify.
channelstringA key under channels: in the data repo's pipelines.yaml. validate checks it.

Details, and what a channel script receives, in Notifications.

steps: fields common to all types

FieldTypeRequiredNotes
idstringyeskebab-case, unique within the pipeline (including always).
typeshell | agentnoDefaults to agent.
cwdstringnoInterpolable. For agent, the sandbox's write root.
inputsstring listno ([])Extra dependencies, each as <step>.<field> (a field the other step declares in outputs). A {{step.field}} reference in run, prompt, when or cwd already creates the edge, so inputs is only needed for a dependency that isn't interpolated anywhere. Order in the YAML doesn't matter; only cycles are rejected.
outputsmap name → typenoThe output contract. Types: string, number, boolean, string[], number[], boolean[]. A step with outputs: must produce valid JSON with exactly those fields.
whenstringnoAn expression over outputs of previous steps or params; see Step-level when.
retryas defaults.retrynoOverrides the default for this step.
on_errorstop | continuenoOverrides the default. continue lets the graph carry on even if this step fails.
timeoutdurationnoOverrides the default.
effectsstring listno ([])Free-form, documentary labels; they don't affect execution.

shell steps

yaml
- id: build  type: shell  cwd: "{{params.docs_repo}}"  when: "{{check-changes.has_changes}}"  timeout: 10m  run: |    set -euo pipefail    yarn build >&2

run runs with sh -c on macOS and bash --posix -c on Linux (Ubuntu's /bin/sh is dash, which rejects set -o pipefail). If the step declares outputs:, all of its stdout must be exactly the contract's JSON. Any other output (git merge or yarn build talking on stdout) breaks parsing: send noise to stderr with >&2 or use --quiet. {{...}} references are replaced by safe environment variables before the shell sees them, never pasted in as raw text, so the command can quote them freely.

agent steps

yaml
- id: screenshots  agent: docs-writer  prompt: steps/screenshots.md  cwd: "{{params.docs_repo}}"  additional_dirs: ["{{params.admin_repo}}"]  when: "{{detect-target.capture_target_slug}}"  mcp_servers: [playwright]  tools:    - Read    - Write    - Edit    - mcp__playwright__browser_navigate    - mcp__playwright__browser_take_screenshot  outputs:    summary: string

agent steps run through the Claude Agent SDK and authenticate with ANTHROPIC_API_KEY, read from the data repo's .env or from the environment.

FieldTypeRequiredNotes
agentstringyesName of agents/<agent>.md in the data repo (YAML front matter + Markdown prompt).
promptstringyesPath to this step's prompt file, relative to the pipeline directory. May contain {{...}} references to outputs of previous steps, params and secrets.
toolsstring listnoDeny by default. Native tools must be in the engine's table (Read, Write, Edit, NotebookEdit, Glob, Grep, StructuredOutput); every mcp__<server>__<suffix> tool needs its server in this step's mcp_servers:, with a known kind: (and, for a declared server, the suffix in its allow). Without tools:, the step gets Read, Grep and Glob. Bash and any tool that runs arbitrary code are always forbidden.
mcp_serversstring listno ([])Names from the pipeline's mcp_servers registry that this step enables.
additional_dirsstring listno ([])Read-only directories outside cwd that the step can reach. Interpolable only with {{params.*}}.
max_cost_usdnumber > 0noSpending cap in USD for this step (the SDK's maxBudgetUsd). No cap if omitted.

How the engine enforces all this is in Agent steps and the sandbox.

Step-level when

Different from the pipeline-level when:: at step level it's a single text expression over outputs of previous steps or params. Two forms, nothing else:

yaml
when: "{{detect-target.capture_target_slug}}"          # truthinesswhen: "{{redact.status}} == \"skipped\""                # comparison

Comparison operators: ==, !=, >, >=, <, <=. The ordering ones require both sides to be numeric. A missing reference (for example, the step it depends on was skipped) is false, not an error, so a step whose predecessor was skipped is skipped in cascade. False values: undefined, null, false, 0, the empty string and the empty array; everything else is true.

always

Cleanup steps that always run when the run closes, success or failure. Deliberately without inputs, outputs, when or on_error: they take no part in the graph or in the run's error policy.

yaml
always:  - id: close-session    run: pkill -f "playwright-server" || true
FieldTypeRequired
idstringyes (kebab-case, unique across the whole pipeline)
runstringyes
cwdstringno
timeoutdurationno (uses defaults.timeout)

Interpolation

{{left.right}} works in any text field of a step, with three sources:

  • {{params.name}}: a resolved param.
  • {{secrets.NAME}}: a secret declared in requires.env with a literal name (not one built from params). Only in the prompt of an agent step. Anywhere else (cwd, run, shell: guards, additional_dirs) it's a validation error, so a secret never ends up in a shell command where it could leak into a log.
  • {{step.field}}: an output of another step. The reference makes this step depend on that one, and field must be declared in the other step's outputs:.

A reference that doesn't resolve (a nonexistent param, an undeclared secret, the output of a step that didn't run) fails explicitly; it's never silently replaced with an empty string.

Two substitution mechanisms, depending on the destination:

  • An agent's prompt and other non-executable fields: direct text substitution.
  • Anything that ends up in the shell (run: of a shell or always step, a shell: guard): each reference becomes the name of a safe environment variable, and the real value travels separately in the child process's environment. The shell never re-interprets the value as shell syntax, whatever characters it contains.

Written from agentic-pipelines at b8b4db7: docs/reference.md.