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

Resources

A resource is something outside the pipeline that has versions: a git branch, a queue of pull requests, a build artifact, a counter in a file. A resource type is the code that knows how to talk to one.

Every example on this page (and every other page) is a complete pipeline, verified by the test suite. Blocks that need the network or real credentials say so; everything else runs as shown.

resources:
- name: repo          # the artifact directory a get creates, and the name steps use
  type: git           # which resource type
  source:             # configuration for that type — free-form, type-specific
    uri: https://github.com/jtarchie/ci.git
    branch: main

jobs:
- name: build
  plan:
  - get: repo         # fetch it — the contents land in ./repo
  - task: compile
    inputs: [repo]
    run: cd repo && go build ./...

git ships with steps, so that pipeline needs no resource_types: block. For anything else, you write the type yourself.

The built-in git type

source: fieldrequiredmeaning
uriyesanything git can clone — https, ssh, or a local path
branchnoomitted follows the remote's HEAD

It fetches the exact commit the plan pinned, shallowly, so a branch that moves mid-run still gives you the version that was planned. It has no out: — put: repo against it is a load error, because what "publish" means (which branch, which credentials, force or not) is a decision only you can make. Write your own type for that.

The built-in slack-mentions and slack-reply types

Two more built-ins, both expression-backed — Slack is a JSON HTTP API and nothing else, so there is no container and no curl/jq dependency to carry. slack-mentions is get-only: every unanswered @mention of the bot in a channel, plus every message in a 1:1 DM (no @mention required there — nobody types one in a 1:1 chat), oldest first, as a {channel, ts, thread_ts} version — ts is the message that named the bot, thread_ts the thread it lives in (the same value, for a top-level message). slack-reply is put-only: posts a message, threaded or top-level.

Cold start does not mean a backlog: like every resource, the first-ever check of a freshly-deployed slack-mentions records everything it finds and answers only the newest of it — see Version history below. That rule is a steps web behavior; steps run has no persisted cursor, so every steps run check is a first check.

A cursorless check is also bounded in what it asks Slack. Finding a mention written as a reply means one conversations.replies call per thread, and the cursor is what normally keeps that set small — no cursor means every thread that has ever been replied to qualifies. On a workspace with more threads than the rate limit allows calls for, that check gets throttled, and a throttled check reports no version, which leaves no cursor for the next one: a watcher that can never run once. So a check with no cursor looks at the five most recently active threads and stops.

Under steps web that costs nothing that is ever built: everything below the newest version a first check reports is marked already-taken, and the cursor it leaves behind lifts the bound for every poll after it. Under steps run the bound never lifts, because no cursor is ever persisted — a mention written inside the sixth-most-recently-active thread is not delivered. Use steps web for a Slack bot; steps run is for a one-shot answer to whatever is most recent.

resources:
- name: mentions
  type: slack-mentions
  source:
    channels: []   # optional; [] (the default) is every channel the bot is in
    limit: 200      # optional; per-channel messages fetched per check, default 200

- name: reply
  type: slack-reply
  source: {}

# A second bot, in the same pipeline, answering as someone else: env: adds
# SECOND_BOT_TOKEN to just THIS resource's own allow-list (the type's own
# env: still only names SLACK_BOT_TOKEN), and source.token_env picks it.
- name: reply-as-support-bot
  type: slack-reply
  env: [SECOND_BOT_TOKEN]
  source:
    token_env: SECOND_BOT_TOKEN

jobs:
- name: answer-mention
  plan:
  - get: mentions
    trigger: true
    version: every    # answer every mention found, not just the newest
  - task: address
    inputs: [mentions]
    outputs: [thread, answer]
    run: |
      set -eu
      grep -o '"channel": *"[^"]*"' mentions/version.json | cut -d'"' -f4 > thread/channel
      grep -o '"thread_ts": *"[^"]*"' mentions/version.json | cut -d'"' -f4 > thread/ts
      echo "got it, working on it" > answer/reply.md
  - put: reply
    inputs: [thread, answer]

Both need SLACK_BOT_TOKEN (a bot token, xoxb-) in the environment, for an app with chat:write, channels:history, channels:read, im:history and im:read (plus groups:history/groups:read for private channels), installed to the workspace and invited to every channel it should watch or post to. Membership is what grants both reading history and appearing in users.conversations, so /invite is the subscribe action — app_mentions:read is not needed, since this polls rather than using the Events API.

1:1 DMs are always watched too, with no @mention required — there is no source: field to turn this off. Group DMs (mpim) are not watched at all; a group is closer to a channel (several humans, bot is one more party) than a 1:1, so it keeps the explicit-mention rule instead.

source: field (either type)requiredmeaning
channels (slack-mentions)no[] watches every channel (and 1:1 DM) the bot is in; a list of ids narrows it
limit (slack-mentions)noper-channel messages fetched per check, default 200
base_url (either)nooverrides https://slack.com — for pointing a test at a fake server
token_env (either)nooverrides SLACK_BOT_TOKEN as the env var name to read the token from

token_env alone isn't enough to widen what a resource can read — env() only sees names its resource TYPE already declares (both types declare SLACK_BOT_TOKEN, shared by every resource of that type), which is what makes it safe for a shared, possibly-external type to hand-in-hand with any expr type at all. A resource naming a different token also needs env: on the resource itself to add that name to its own allow-list — env: and source: together, as in reply-as-support-bot above. Naming token_env without the matching env: entry is a run-time error (env(...): not in this resource type's env:), not a silent fall-back to SLACK_BOT_TOKEN. (env: on a resource only means something for an expr- or shell-backed type — an mcp-backed type authenticates via its mcp_servers: entry and rejects env: at load time.)

A mention inside a thread arrives with its thread. mentions/thread.json is the whole conversation the mention was written in (Slack's conversations.replies payload: parent first, then replies), fetched by thread_ts — asking Slack for a reply's ts answers with that one message and nothing around it, which is an agent being handed a question with no context. Post the answer back with thread_ts too, as the example above does: a reply's ts is not a thread id.

A mention inside a thread counts, and limit: is what decides whether it can be found. Slack's conversations.history returns top-level messages only, and a reply does not change its parent's ts — so the window the check reads channel history over is limit messages, deliberately wider than the cursor, and the cursor decides only what inside that window is new. A thread whose parent carries a latest_reply newer than the cursor is read; every other thread costs nothing.

Two known gaps remain, both bounded by limit: and both needing Slack pagination to close:

slack-reply's put: reads its message from files an upstream step writes, not params: — file() takes what inputs: put on disk directly, so a reply containing backticks or $(…) is data, never something a shell might run:

filerequiredmeaning
thread/channelyesthe channel id to post to
thread/tsnoa parent message's ts — posts as a reply in that thread; omit to post a new top-level message
answer/reply.mdyesthe message text

There is deliberately no check:/in: on slack-reply and no out: on slack-mentions — get: reply or put: mentions are both load errors, the same rule git's missing out: follows.

Writing a resource type

A resource type is three shell commands. (For a resource that is a JSON HTTP API and nothing else, there is a second way to write them — see expression resource types, which trades containers and binary artifacts for concurrent HTTP and no dependency on curl/jq.) Each is a template and each runs sh -c. This one is self-contained, so it runs anywhere:

resource_types:
- name: greetings
  # image: alpine:3   # optional — run these in a container instead of on the host
  config:
    check: |
      printf '[{"word": "hello"}, {"word": "hola"}]'
    in: |
      echo {{ .version.word | shellquote }} > word.txt

resources:
- name: greeting
  type: greetings
  source: {}

jobs:
- name: speak
  plan:
  - get: greeting
  - task: shout
    inputs: [greeting]
    run: tr a-z A-Z < greeting/word.txt
    assert:
      stdout: HOLA           # check printed oldest-first, so the LATEST is "hola"
  assert:
    execution: [greeting, shout]
    outcome: succeeded

check — what versions exist?

Runs when a plan is built, and on every steps web poll.

[{"ref": "9fceb02"}, {"ref": "d7b22a6"}]

The check cursor

{{ .version }} is the newest version the last successful check reported — Concourse calls it the current version. It exists so a check can ask its API for what it has not seen instead of guessing a window:

# a guess, and the only thing between you and both failure modes below
check: |
  curl -sS ... --data-urlencode 'limit=20' https://api.example.com/messages

# ask for exactly what we haven't seen
check: |
  curl -sS ... --data-urlencode 'since={{ index .version "ts" | default "0" }}' \
               --data-urlencode 'limit=200' https://api.example.com/messages

Guess too small and items scroll past during a busy period — and while history means a version steps already recorded is not lost, one it never saw at all cannot be recovered by anything. Guess anything at all and a cold start sees a backlog it must not answer — it builds only the newest of what it finds.

Three things to know:

Version history

steps remembers every version it has seen of a resource, in the order it first saw them. That record is what a triggered job actually builds from — it does not re-run check for the versions it was triggered for.

The reason is that a cursor-driven check cannot be asked twice. The second answer is different, because the first answer moved the cursor: a job re-deriving its own versions would ask "what is new since the versions I was just handed" and correctly get nothing. A lookup is repeatable, so plan time and run time agree without anything being passed between them.

History is also what makes a version recoverable. Before it, whatever check returned right now was the whole universe — a version that scrolled out of the window while nothing was watching was gone, and no amount of cursor bookkeeping could bring it back.

Three things follow:

How much to remember

defaults.version_history: caps it per resource, keeping the newest (0 keeps everything, as every limit here does):

# The cap itself is not observable in one run — internal/store/sqlite's tests measure
# the pruning. This example pins only that the field loads and a capped
# resource still fetches normally.
defaults:
  # A git branch produces a version per push; a chat feed one per message.
  # The right number is a property of what you watch.
  version_history: 50

resource_types:
- name: counter
  config:
    check: |
      printf '[{"n": "1"}, {"n": "2"}]'
    in: echo {{ .version.n | shellquote }} > n.txt

resources:
- name: ticks
  type: counter
  source: {}

jobs:
- name: build
  plan:
  - get: ticks
  - task: show
    inputs: [ticks]
    run: cat ticks/n.txt
    assert:
      stdout: "2"
  assert:
    execution: [ticks, show]
    outcome: succeeded

--version-history sets a default for a pipeline that does not; when neither says, steps keeps 1000. Whatever the limit, the newest versions are the ones kept.

resource_types:
- name: since-cursor
  config:
    # A real type would send this to an API. Here it just reports what it was
    # handed, which is the part worth seeing: on a fresh run there is no
    # cursor, so the default is what the check gets.
    check: |
      printf '[{"seen": "%s"}]' '{{ index .version "ts" | default "0" }}'
    in: echo {{ .version.seen | shellquote }} > seen.txt

resources:
- name: feed
  type: since-cursor
  source: {}

jobs:
- name: poll
  plan:
  - get: feed
  - task: show
    inputs: [feed]
    run: cat feed/seen.txt
    assert:
      stdout: "0"          # nothing recorded yet, so the check saw the default
  assert:
    execution: [feed, show]
    outcome: succeeded

in — fetch one version

Runs when a get step executes.

params: on a get is how a resource is told how to fetch, as opposed to source:, which says what to fetch. The distinction matters because source: belongs to the resource and params: belongs to the step, so one resource can be fetched differently by different jobs without being declared twice:

resource_types:
- name: notes
  config:
    check: |
      printf '[{"ref": "v1"}]'
    in: |
      head -n {{ index .params "lines" | default "100" }} <<'EOF' > notes.txt
      first line
      second line
      EOF

resources:
- name: log
  type: notes
  source: {}

jobs:
- name: quick
  plan:
  - get: log
    params: { lines: 1 }     # this job fetches a truncated view
  - task: show
    inputs: [log]
    run: wc -l < log/notes.txt
    assert:
      stdout: "1"            # the param reached in:
  assert:
    execution: [log, show]
    outcome: succeeded
- name: full
  plan:
  - get: log                 # same resource, whole thing
  - task: show
    inputs: [log]
    run: wc -l < log/notes.txt
    assert:
      stdout: "2"            # no param, so the default won — same resource, other view
  assert:
    execution: [log, show]
    outcome: succeeded

assert:
  execution: [quick, full]

Optional params take the same shape as an optional source: field (see Shell safety below). Templates render with missingkey=error, so a bare {{ .params.lines }} makes lines mandatory on every get of that type; {{ index .params "lines" | default "100" }} works on an absent key and on a get with no params: block at all.

Params change the fetch, so they change the hash. Two gets of one version differing in params: are two different fetches: they get distinct cache entries and neither is reused for the other. A get with no params: hashes exactly as it did before the field existed, so adding this to a pipeline invalidates nothing that does not use it.

The fetched directory is named after the get:, so get: log puts it in log/, and later steps read log/.... See workspace.md for what a step can and can't see.

out — publish something

Runs when a put step executes. Optional: a type with no out: is read-only, and a put: against it is rejected at load time rather than silently doing nothing.

A put publishes; it does not fetch

A put step runs out: and nothing else — there is no implicit get afterward, so a put produces no artifact. (Concourse fetches the produced version automatically; steps deliberately does not: an artifact appearing in the build that no step declared is exactly the kind of ambient data flow this DSL rejects.) A plan that wants the resource's contents after a put writes the fetch it means:

resource_types:
- name: release
  config:
    check: |
      printf '[{"ref": "v1.4.1"}]'
    in: echo {{ .version.ref | shellquote }} > ref
    out: |
      cat notes/summary.txt        # "publish" the summary an earlier step wrote
      printf '{"ref": "v1.4.2"}'

resources:
- name: releases
  type: release
  source: {}

jobs:
- name: publish
  plan:
  - task: summarize
    outputs: [notes]
    run: echo 'what changed' > notes/summary.txt
  - put: releases              # out: publishes and prints v1.4.2
    inputs: [notes]
  - get: releases              # fetch the resource, explicitly
  - task: verify
    inputs: [releases]
    run: cat releases/ref
    assert:
      # check's answer, NOT the v1.4.2 the put just printed — the get was
      # pinned when the plan was built, before out: ran. See below.
      stdout: v1.4.1
  assert:
    execution: [summarize, releases, releases, verify]   # put, then get — two entries
    outcome: succeeded

The explicit get fetches the version check reported when the plan was built — check runs once, before any step, so the version the put publishes mid-run is not what the same run's get fetches (the example above pins exactly that: out: prints v1.4.2, the get still fetches v1.4.1). The version a put prints is recorded with the run; it reaches gets in later runs, once a check has reported it — a downstream job triggered on the resource is the plan shape that consumes what a put published. A put whose output nothing reads simply has no get after it.

Shell safety

Anything interpolated into a command is text substitution, so quote it:

check: git ls-remote {{ .source.uri | shellquote }}     # good
check: git ls-remote {{ .source.uri }}                  # a uri with a space or ; breaks or worse

shellquote renders a value as one safely-quoted shell word. Use it for every {{ }} that reaches a command. See templating.md.

Templates render with missingkey=error, so reading an optional field that wasn't set fails the render. Ask for optional fields in a way that can answer "nothing":

{{ index .source "branch" | default "HEAD" }}     # optional
{{ .source.uri }}                                 # required — failing is correct

version: on a get step

By default a get fetches the latest version check reported. version: every runs the rest of the plan once per version, and a mapping pins one exact version:

resource_types:
- name: builds
  config:
    check: |
      printf '[{"number": "87"}, {"number": "88"}]'
    in: echo {{ .version.number | shellquote }} > number.txt

resources:
- name: build
  type: builds
  source: {}

jobs:
- name: latest-only
  plan:
  - get: build                     # default: "88", the newest
  - task: show
    inputs: [build]
    run: cat build/number.txt
    assert:
      stdout: "88"
  assert:
    execution: [build, show]
    outcome: succeeded
- name: each-in-turn
  plan:
  - get: build
    version: every                 # the rest of the plan runs per version
  - task: show
    inputs: [build]
    run: cat build/number.txt      # no stdout assert: this runs once per version
  assert:
    execution: [build, show, build, show]   # the fan-out, one pass per version
    outcome: succeeded
- name: pinned
  plan:
  - get: build
    version: { number: "87" }      # exactly this one
  - task: show
    inputs: [build]
    run: cat build/number.txt
    assert:
      stdout: "87"                 # the pin won over the newer version
  assert:
    execution: [build, show]
    outcome: succeeded

assert:
  execution: [latest-only, each-in-turn, pinned]

Under every, a failing version does not stop the remaining ones from being attempted.

every takes each version once

A check reports what exists, not what is new — the same twenty Slack messages, the same page of builds, on every poll. So every remembers: once a version's fan-out has succeeded, that version is not taken again, and a later run fans out only over what is left. Without that, a plan ending in a put: or an agent: — the two steps the cache deliberately never skips, because their worth is an effect rather than an artifact — repeats every effect it has ever performed each time anything new shows up.

steps plan reads the same record, so it lists only the versions a run would actually take.

Several every gets: input sets

When more than one get says every, a run resolves input sets, Concourse's model: each every get advances one step per set through its own unbuilt versions, in lockstep with its siblings, and one build runs per set. A get whose versions run out holds at the newest version it has already covered while the others keep moving. There is no cross product — 3 new versions on one input and 2 on another mean three builds, not six.

The hold rule is what makes the steady state right, not just the burst: updates rarely arrive in matched pairs. config moving alone builds (code@held, config@new); code catching up later builds (code@new, config@held).

resource_types:
- name: builds
  config:
    check: |
      printf '[{"number": "87"}, {"number": "88"}, {"number": "89"}]'
    in: echo {{ .version.number | shellquote }} > number.txt
- name: configs
  config:
    check: |
      printf '[{"rev": "5"}, {"rev": "6"}]'
    in: echo {{ .version.rev | shellquote }} > rev.txt

resources:
- name: build
  type: builds
  source: {}
- name: conf
  type: configs
  source: {}

jobs:
- name: pairwise
  plan:
  - get: build
    version: every
  - get: conf
    version: every
  - task: show
    inputs: [build, conf]
    # The case pins the pairing, not just the count: a cross product would
    # build (87,6), (88,5) or (89,5) and fail here.
    run: |
      pair="$(cat build/number.txt)-$(cat conf/rev.txt)"
      case "$pair" in
        87-5|88-6|89-6) echo "took $pair" ;;
        *) echo "unexpected pair $pair"; exit 1 ;;
      esac
    assert:
      stdout: took
  assert:
    # Three sets, not six: (87,5), (88,6), then conf is exhausted and
    # HOLDS at rev 6 while build keeps moving — (89,6).
    execution: [build, conf, show, build, conf, show, build, conf, show]
    outcome: succeeded

assert:
  execution: [pairwise]

trigger: true

Marks a get as something steps web should poll. When its version changes, the jobs containing it run automatically. Valid only on get steps — setting it anywhere else is a load-time error.

resource_types:
- name: ticker
  config:
    check: |
      printf '[{"tick": "1"}]'
    in: echo {{ .version.tick | shellquote }} > tick.txt

resources:
- name: clock
  type: ticker
  source: {}

jobs:
- name: on-change
  plan:
  - get: clock
    trigger: true      # steps web polls this; a new version runs the job
  - task: react
    inputs: [clock]
    run: cat clock/tick.txt
    assert:
      stdout: "1"
  assert:
    execution: [clock, react]
    outcome: succeeded

See infra.md for the watch loop, webhooks, and cross-job triggering. Gating a get on upstream jobs — Concourse's passed: — is there too: infra.md#passed.

MCP-backed types

A resource type can call an MCP server instead of running shell commands — the same check/in/out roles, as tool calls. See mcp.md.

Checking your work

steps validate pipeline.yml answers "does it parse and hang together"; steps plan pipeline.yml runs check and shows what would be fetched vs cached — the fastest way to see whether a check you just wrote returns what you expect, since it resolves versions without running the rest of the job.