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
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | kebab-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. |
description | string | yes | Non-empty. |
version | number | yes | Integer ≥ 1. Persisted in every run record (pipelineVersion) so a resume knows which version the original attempt ran against. |
params | map | no | See params. |
requires | object | no | See requires. |
mcp_servers | map | no | See mcp_servers. |
when | list of guards | no | See when. |
triggers | list | no | See triggers. |
defaults | object | no | See defaults. |
notify | object | no | See notify. |
steps | list | yes (at least 1) | See steps. |
always | list | no | See 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.
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 field | Type | Required | Notes |
|---|---|---|---|
description | string | yes | |
required | boolean | no | If true and there's no value after resolving, run fails with a list of the missing params. |
default | string, number or boolean | no | Always 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.
requires: agents: [docs-writer, docs-reviewer] bin: [git, jq, yarn] env: - DOCS_ADMIN_URL - DEPLOY_TOKEN net: [git.example.com] skills: []| Field | Type | Check in doctor |
|---|---|---|
agents | string list | agents/<name>.md exists in the data repo. |
skills | string list | The skill is available in the user's Claude Code environment. |
bin | string list | The binary is on the PATH. |
env | string list | Secret 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. |
net | string list | The host resolves via DNS. |
Secret names that depend on params
A generic pipeline can declare secrets whose name depends on its params:
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}}_PASSWORDWith --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;
validaterejects{{secrets.*}}and{{step.field}}in these names. - The resulting name must be a valid variable name (letters, digits and
_, not starting with a digit). Amy-appor9appparam produces an invalid name, and the preflight rejects it. pipelines doctoraccepts--setto 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 anagentstep: 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).
mcp_servers: playwright: command: npx args: ["-y", "@playwright/mcp@0.0.79", "--headless", "--no-sandbox"] kind: playwright| Field | Type | Required | Notes |
|---|---|---|---|
| map key | string | — | Server name, kebab-case. It becomes part of the mcp__<name>__<tool> prefix you write by hand in a step's tools:. |
command | string | yes | Interpolable with {{params.*}}, never {{secrets.*}}. |
args | string list | no ([]) | Interpolable like command. |
env | string list | no ([]) | Names of the secrets this server needs, resolved against requires.env. |
kind | "playwright" or "declared" | no | Without 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. |
allow | string list | with 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:
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.
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:
| Guard | Shape | Passes 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. |
shell | string | The 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
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.
defaults: on_error: stop retry: 0 timeout: 15m| Field | Type | Default |
|---|---|---|
on_error | stop | continue | stop |
retry | number of attempts, or { attempts: number, on: transient | any } | 0 |
timeout | duration (\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
notify: on: [success, failed] stale_after: 72h channel: email| Field | Type | Notes |
|---|---|---|
on | list of success | failed | skipped | Every path that closes a run notifies, including a skipped guard and a busy lock. |
stale_after | duration | Optional. 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. |
channel | string | A 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
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | kebab-case, unique within the pipeline (including always). |
type | shell | agent | no | Defaults to agent. |
cwd | string | no | Interpolable. For agent, the sandbox's write root. |
inputs | string list | no ([]) | 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. |
outputs | map name → type | no | The output contract. Types: string, number, boolean, string[], number[], boolean[]. A step with outputs: must produce valid JSON with exactly those fields. |
when | string | no | An expression over outputs of previous steps or params; see Step-level when. |
retry | as defaults.retry | no | Overrides the default for this step. |
on_error | stop | continue | no | Overrides the default. continue lets the graph carry on even if this step fails. |
timeout | duration | no | Overrides the default. |
effects | string list | no ([]) | Free-form, documentary labels; they don't affect execution. |
shell steps
- id: build type: shell cwd: "{{params.docs_repo}}" when: "{{check-changes.has_changes}}" timeout: 10m run: | set -euo pipefail yarn build >&2run 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
- 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: stringagent steps run through the Claude Agent SDK and authenticate with ANTHROPIC_API_KEY, read from the data repo's .env or from the environment.
| Field | Type | Required | Notes |
|---|---|---|---|
agent | string | yes | Name of agents/<agent>.md in the data repo (YAML front matter + Markdown prompt). |
prompt | string | yes | Path to this step's prompt file, relative to the pipeline directory. May contain {{...}} references to outputs of previous steps, params and secrets. |
tools | string list | no | Deny 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_servers | string list | no ([]) | Names from the pipeline's mcp_servers registry that this step enables. |
additional_dirs | string list | no ([]) | Read-only directories outside cwd that the step can reach. Interpolable only with {{params.*}}. |
max_cost_usd | number > 0 | no | Spending 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:
when: "{{detect-target.capture_target_slug}}" # truthinesswhen: "{{redact.status}} == \"skipped\"" # comparisonComparison 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.
always: - id: close-session run: pkill -f "playwright-server" || true| Field | Type | Required |
|---|---|---|
id | string | yes (kebab-case, unique across the whole pipeline) |
run | string | yes |
cwd | string | no |
timeout | duration | no (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 inrequires.envwith a literal name (not one built from params). Only in thepromptof anagentstep. 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, andfieldmust be declared in the other step'soutputs:.
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 ashelloralwaysstep, ashell: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.