steps docs
start here Writing pipelines resourcesexprcontrol-flowagentsattempts-timeoutworkspaceinfratemplatingmcpcomplete Reference webagents-internalsaws-workersgcp-workersconformance

Template Rendering

Resource check/in/out commands and agent custom tools are Go templates rendered before they run: {{ .source.* }} and {{ .version.* }} for resources, plus {{ .args.* }} in a custom tool's run: (where the tool's parameter schema is inferred from exactly those references — see agents.md).

Templates have the full slim-sprig function library available (string/list/default/date helpers) plus our own shellquote:

resource_types:
- name: shouts
  config:
    check: |
      printf '[{"word": "%s"}]' {{ .source.word | upper | shellquote }}
    in: |
      echo {{ .version.word | shellquote }} > word.txt

resources:
- name: shout
  type: shouts
  source: { word: hello }

jobs:
- name: demo
  plan:
  - get: shout
  - task: show
    inputs: [shout]
    run: cat shout/word.txt
    assert:
      stdout: HELLO                # `upper` ran in the check template, not here
  assert:
    execution: [shout, show]
    outcome: succeeded

Shell-quoting untrusted values

A rendered template runs via sh -c, so any interpolated value that could contain shell metacharacters — backticks, $(...), quotes, ; | & — must be piped through shellquote, which renders it as one safely-quoted POSIX word and supplies its own quotes:

run: gh pr comment --body {{ .args.body | shellquote }}

Don't add surrounding "..." — shellquote already quotes only when needed. This matters most for LLM- or PR-authored values (e.g. a review body): without it, a body containing `replace` gets command-substituted by the shell and posted with those words silently missing.

Pipeline vars: ((var)) and load_var:

Separate a pipeline's shape from its parameters, so one file serves staging and production instead of being copy-pasted per environment and drifting:

resource_types:
- name: echoes
  config:
    check: |
      printf '[{"ref": "v1"}]'
    in: |
      echo {{ .source.uri | shellquote }} > uri.txt

resources:
- name: repo
  type: echoes
  source:
    uri: ((repo_uri))        # supplied at run time, not written in the file

jobs:
- name: build
  plan:
  - get: repo
  - task: show
    inputs: [repo]
    run: cat repo/uri.txt
    assert:
      # the exact value --var supplied (the command below) reached the template
      stdout: github.com/acme/app-staging
  assert:
    execution: [repo, show]
    outcome: succeeded
steps run pipeline.yml --job build --var repo_uri=https://github.com/acme/app-staging
steps run pipeline.yml --job build --vars-file prod.yml

Both compose: the file is the shared, checked-in set and a --var flag overrides it, which is the only ordering that makes a one-off override possible.

Capturing a value the run itself produces:

jobs:
- name: release
  plan:
  - task: pick-tag
    outputs: [meta]
    run: printf 'v1.2.3\n' > meta/version.txt
  - load_var: tag
    inputs: [meta]           # the load_var step reads from its own declared input
    file: meta/version.txt
  - task: announce
    run: echo "releasing ((tag)) ok"
    assert:
      # the captured value, trimmed, substituted — untrimmed, version.txt's
      # trailing newline would land before "ok" and break this line
      stdout: releasing v1.2.3 ok
  assert:
    execution: [pick-tag, tag, announce]
    outcome: succeeded

The inputs: on the load_var step is not optional bookkeeping: a step's directory holds only the artifacts it declares, so a bare file: version.txt names nothing that exists. Both halves are checked at plan time — the file must sit inside a declared input, and that input must be something an earlier step produced.

load_var: names share the one ((var)) namespace, so a name both passed with --var and captured by a load_var: goes to --var: supplied vars substitute textually before the file is parsed, leaving no ((name)) for the captured value to fill — the load_var: step still runs, but substitutes nothing. (Concourse keeps run-captured vars in a separate ((.:name)) namespace instead; see conformance.md.)

⚠️ Vars are config, not secrets

A substituted value is parsed, hashed, and stored in state.db like anything else written in the file. Vars separate shape from parameters; they are not a secret store. Keep credentials in the env-var references that exist for them (api_key_env:), which are read at run time and never enter the merkle content.

The rest