Skip to content

Flows & Steps

A flow is a YAML file that describes an automation pipeline. Stepyard reads the file, resolves expressions, and executes each step in order (unless you use control flow to change the sequence).


Anatomy of a flow file

flows/example.yaml
# yaml-language-server: $schema=../.stepyard/flow.schema.json  (1)

name: example                      # (2)
description: What this flow does.  # (3)

env:                               # (4)
  LOG_LEVEL: info
  BASE_URL: https://api.example.com
dotenv: .env                       # (5)

trigger:                           # (6)
  uses: cron
  with:
    schedule: "0 9 * * 1-5"

steps:                             # (7)
  - id: fetch                      # (8)
    uses: http.request             # (9)
    with:                          # (10)
      url: ${{ env.BASE_URL }}/data
      method: GET

  - id: process
    uses: shell.run
    with:
      command: echo "${{ steps.fetch.output.body }}"
  1. Modeline for VS Code / Cursor - enables live autocompletion. Generated by stepyard schema.
  2. name - machine-readable identifier. Used as the argument to stepyard run <name>.
  3. description - optional human description. Shown in stepyard status.
  4. env - optional map of inline environment variable defaults (see Environment variables below).
  5. dotenv - optional path (or list of paths) to .env files to load (see Loading from a file below).
  6. trigger - optional. Omit for manually-run flows. See Triggers.
  7. steps - ordered list of execution units.
  8. id - unique identifier within the flow. Used to reference this step's output in later steps.
  9. uses - the node type to execute. Format: namespace.action.
  10. with - key/value map of inputs passed to the node. Values can be literals or ${{ }} expressions.

Step fields

Every step supports the following top-level fields:

Field Type Required Description
id string Unique identifier within the flow
uses string ✓ (or steps) Node type, e.g. shell.run
with mapping Input arguments passed to the node
steps list Nested sub-steps (group step, no uses)
if expression Skip this step when falsy
loop expression Iterate over a list
while expression Repeat while truthy
next string / expression Override which step runs next
max_visits int Max times this step can run (default: 1, 0 = unlimited)
continue_on_error bool Don't stop the flow on failure
approval bool Pause for human approval before running (via the built-in approval hook)
retry int / mapping Automatic retry configuration
timeout string Maximum execution time, e.g. "30s" or "5m"

Retry shorthand

Set retry to an integer for a fixed number of attempts with default backoff:

  - id: download_artifact
    retry: 3
    uses: http.download
    with:
      url: https://cdn.example.com/artifact.tar.gz
      dest: ./artifacts/artifact.tar.gz

For custom delay and backoff, use a mapping - see Error Handling - retry.

What counts as failure?

shell.run always completes successfully and returns code in its output - a non-zero exit does not mark the step failed. http.request returns 4xx/5xx responses as output without failing; connection errors still fail the step. See Error Handling.


Outputs

After a step runs, its outputs are available to later steps via ${{ steps.<id>.output.<field> }}.

The available fields depend on the node. For example, shell.run produces:

Field Description
stdout Combined standard output (stderr is merged here)
stderr Always empty - stderr is merged into stdout
code Exit code (0 = command succeeded; the step still completes even when code != 0)

http.request produces:

Field Description
status HTTP status code
body Parsed JSON object or raw string
headers Response headers dict

See Built-in Nodes for all output fields.


Step status

After execution, each step is in one of these states:

Status Meaning
completed Ran successfully
failed Node raised an exception or returned a failed result (see note below)
skipped The if condition was falsy
waiting_for_approval A hook paused execution waiting for a human
waiting_for_input A human.input node is waiting for a value
cancelled The user or the system cancelled the run

File layout

Stepyard looks for flows in the flows/ directory of your project root. The project root is the nearest ancestor directory that contains a .stepyard/ folder.

my-project/
├── .stepyard/          # created by stepyard init
└── flows/
    ├── deploy.yaml
    └── backup.yaml

The name field in the YAML file determines the run name; the argument to stepyard run is the file stem (filename without .yaml). Only top-level files inside flows/ are resolved - subdirectory nesting is not yet supported.


Environment variables (env)

Declare environment variables once at the top of a flow with the env: key. No CLI flags or extra files needed - just add the block and run:

name: deploy
env:
  ENVIRONMENT: staging
  RETRY_COUNT: 3

steps:
  - id: apply
    uses: shell.run
    with:
      # $ENVIRONMENT is a real OS env var in the subprocess
      command: kubectl apply -f k8s/$ENVIRONMENT/

  - id: notify
    if: "${{ env.ENVIRONMENT == 'production' }}"
    uses: shell.run
    with:
      command: echo "Deployed to production"

How it works

  • Values are strings. Numbers and booleans are coerced: 42"42", true"true".
  • Each value is set as a real OS environment variable before any step runs, so shell.run subprocesses can read them as $NAME (or %NAME% on Windows) without extra configuration.
  • Every ${{ env.NAME }} expression in with, if, loop, while, and next fields also resolves to the declared value.
  • Precedence: an existing shell/CI/.env variable with the same name is not overwritten. Flow env acts as a default, which means you can always override declared values from the outside without touching the YAML.

Loading from a file (dotenv:)

Instead of (or alongside) inline env: values, point to a .env file with the dotenv: key:

name: deploy
dotenv: .env.staging          # relative to the project root

steps:
  - id: apply
    uses: shell.run
    with:
      command: kubectl apply -f k8s/$ENVIRONMENT/

The file uses the standard key=value format (comments with # are ignored, values can be quoted):

.env.staging
ENVIRONMENT=staging
RETRY_COUNT=3
# DB_PASSWORD=secret  ← keep real secrets out of dotenv committed to git

Load multiple files by passing a list - the first file in the list takes precedence when a key appears in more than one:

dotenv:
  - .env.local    # developer overrides - highest priority
  - .env.staging  # environment defaults
  - .env          # base defaults - lowest priority

Full precedence order (highest → lowest):

  1. OS / shell environment (CI variables, export, etc.)
  2. Inline env: values
  3. dotenv: file values (first file wins across multiple files)

Paths are resolved relative to the project root directory. A warning is logged if a file is not found, but the run continues.

What belongs here vs vars

env: --var / --env-file
Declared in the YAML file command line at run time
Referenced as $NAME or ${{ env.NAME }} ${{ vars.NAME }}
Best for non-secret defaults (URLs, flags, levels) per-run or per-environment values
Commit to git? yes (no secrets) no

Keep secrets out of env:

env: values live in the flow YAML file - don't use them for API keys, passwords, or tokens. Use shell environment variables, a project .env file, or a secrets manager instead (see How to manage secrets).


Variables (vars)

You can pass arbitrary key/value pairs to a flow at runtime using --var:

stepyard run deploy --var env=production --var version=1.2.3

Inside the flow, access them with ${{ vars.env }}:

  - id: deploy
    uses: shell.run
    with:
      command: kubectl apply -f k8s/${{ vars.env }}/

Or load them from a .env file:

stepyard run deploy --env-file .env.production

Group steps (no uses)

A step without uses is a group: it groups nested steps, optionally under an if or loop:

  - id: deploy_per_region
    loop: ${{ ["us-east-1", "eu-west-1", "ap-southeast-1"] }}
    steps:
      - id: apply
        uses: shell.run
        with:
          command: kubectl apply -f k8s/ --context=${{ item }}

      - id: verify
        uses: http.request
        with:
          url: https://${{ item }}.myapp.com/healthz

Timeout

Limit how long a step can run with timeout. Accepts a duration string:

  - id: slow_query
    uses: shell.run
    timeout: "2m"       # "30s", "5m", "1h" - all supported
    with:
      command: psql ${{ env.DB_URL }} -c "SELECT * FROM big_table"

If the step exceeds the timeout, it is cancelled and marked failed.