Workflow step reference
Step configuration selects executable behavior with type. Omitting type selects command. Step IDs are local expression aliases and must not contain :, which is reserved for generated occurrence IDs.
Step Types
| Type | Purpose |
|---|---|
command |
Invoke a Spec Kit command (e.g., speckit.plan) |
prompt |
Send an arbitrary prompt to the AI coding agent |
shell |
Execute a shell command and capture output |
github |
Add an explicit issue or pull-request label |
init |
Bootstrap a project (like specify init) |
slot |
Named workflow slot; skipped when unfilled |
gate |
Pause for human approval before continuing |
if |
Conditional branching (then/else) |
switch |
Multi-branch dispatch on an expression |
while |
Loop while a condition is true |
do-while |
Execute at least once, then loop on condition |
fan-out |
Dispatch a step for each item in a list |
fan-in |
Aggregate results from a fan-out step |
workflow |
Call an installed workflow with private inputs and declared outputs |
Command Steps (default)
Invoke an installed Spec Kit command by name via the integration CLI:
- id: specify
command: speckit.specify
input:
args: "{{ inputs.spec }}"
integration: claude # Optional: override workflow default
model: "claude-sonnet-4-20250514" # Optional: override model
For positional arguments and named runtime options, see Per-step integration configuration.
Prompt Steps
Send an arbitrary inline prompt to an integration CLI (no command file needed):
- id: security-review
type: prompt
prompt: "Review {{ inputs.file }} for security vulnerabilities"
integration: claude
Capturing Command and Prompt Output
command and prompt steps stream the integration CLI's output to the
terminal, so steps.<id>.output.stdout and steps.<id>.output.stderr are empty
strings. Set capture: true to retain that output in the step result instead,
where later steps can read it:
- id: verdict
type: prompt
prompt: "Reply with exactly READY or BLOCKED for {{ inputs.spec }}"
capture: true
capture_max_bytes: 4096 # Optional: per-stream limit (default 1048576)
timeout: 120 # Optional: seconds before the CLI is killed
- id: branch
type: if
condition: "{{ steps.verdict.output.stdout | contains('READY') }}"
then:
- id: plan
command: speckit.plan
| Field | Applies to | Meaning |
|---|---|---|
capture |
command, prompt |
true captures stdout and stderr instead of streaming them. Must be a literal boolean; default false. |
capture_max_bytes |
command, prompt |
Limit for each captured stream: a positive integer, or -1 for no limit. Default 1048576 (1 MiB). Requires capture: true. |
timeout |
command, prompt |
Seconds before the CLI is killed; with capture: true, its process group (POSIX) or job object (Windows) is killed, which includes the processes it started unless they detached from it, as a daemonising helper can. For command it applies only with capture: true (default 600); a streaming command step has no timeout. For prompt it always applies (default 300). |
A captured step prints nothing while it runs. Its output gains these fields:
| Output field | Meaning |
|---|---|
stdout, stderr |
The captured text. |
stdout_truncated |
true when stdout exceeded capture_max_bytes and the rest was discarded. |
stderr_truncated |
The same for stderr. |
timed_out |
true when the CLI and its processes were killed at timeout; exit_code is -1. |
interrupted |
true when the run was interrupted (Ctrl+C); exit_code is 130. |
What the text is. Capture requests the integration CLI's normal text output, the same output a streaming step prints. It is not a structured format: it can include progress or tool-use lines as well as the final answer, it differs between integrations, and it is valid JSON only if the agent printed nothing but JSON.
Limits and encoding. capture_max_bytes counts raw bytes on each stream
before decoding. Bytes past the limit are read and discarded so the CLI is never
blocked, and the matching *_truncated field is set. Output is decoded as
UTF-8: invalid bytes become U+FFFD, and a character split by the limit, a
timeout, or an interruption is dropped rather than replaced. Line endings are
normalised to \n.
With capture_max_bytes: -1 nothing is discarded and both *_truncated fields
stay false. The whole output is then held in memory and written to run state,
so an agent that prints a large amount grows both without bound. Use it only
for output you know is small.
Check completeness before using the output. Filters such as from_json do
not look at these fields; they parse whatever text is there. Output is
incomplete when stdout_truncated is true, and also when timed_out or
interrupted is true — a stopped CLI leaves partial output even with no size
limit. Guard the use:
- id: parse
type: if
condition: "{{ steps.verdict.output.exit_code == 0 and not steps.verdict.output.stdout_truncated }}"
then:
- id: use
type: shell
run: "echo parsed"
Failures. A non-zero exit, a timeout, or an interruption fails the step and
keeps whatever was captured in output.stdout and output.stderr. The step's
error is a short summary (Command exited with code 2,
Prompt timed out after 120 seconds.), never the captured text. A failed step
stops the run, so set continue_on_error: true on the capturing step when a
later step needs to inspect the failure:
- id: verdict
type: prompt
prompt: "Reply with exactly READY or BLOCKED"
capture: true
timeout: 120
continue_on_error: true
- id: on-timeout
type: if
condition: "{{ steps.verdict.output.timed_out }}"
then:
- id: review
type: gate
message: "The verdict step timed out. Approve to continue without it."
⚠️ Captured output is stored in plain text and is not redacted. It is written to the run's
state.jsonso thatspecify workflow resumecan replay it, and it stays there after the run. Anything the agent prints — including secrets it read from files or the environment — is persisted. Captured text is not printed to the terminal and is not written tolog.jsonl. Do not enable capture on a step whose output may contain credentials unless the run directory is protected accordingly.
Captured text is agent output and therefore untrusted; see
Interpolation and shell safety before using
it in a shell step.
Per-Step Integration Configuration
Command steps may pass structured runtime configuration to integrations that support it:
- id: implement-with-docker-agent
type: command
command: speckit.implement
integration: docker-agent
integration_args:
- "{{ inputs.agent_config }}"
integration_options:
agent: root
safety: balanced
model: "openai/gpt-5"
input:
args: "{{ inputs.spec }}"
integration_args is an ordered list of strings. integration_options is a
mapping with string keys. Values in both fields are resolved with the workflow
expression mechanism and validated by the selected integration; unsupported,
unknown, or malformed values fail with an actionable error. Docker Agent uses
its single positional argument as the agent configuration reference and accepts
agent and safety as named integration options. Configure its model through
the command step's top-level model field.
When Docker Agent integration_args supplies an agent reference for a command
step, it takes precedence over SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS;
the entire legacy environment value is ignored for that step. Without a per-step
agent reference, the legacy environment behavior is unchanged.
Resolved runtime configuration is recorded in workflow run state. When a failed
or paused command is resumed, the complete dispatch configuration is re-resolved
from the current inputs, so values supplied with workflow resume --input take
effect consistently. A resume without updated inputs reproduces the same values.
Shell step
type: shell executes run through the platform shell, retaining support for
pipes, redirects, and multiple commands. Set strict: true to reject {{
expression markers anywhere in run, including inside quotes. This is checked
both during validation and immediately before execution. strict must be a
boolean; omitted or false preserves existing raw interpolation behavior.
Use an env mapping to bind dynamic values without inserting them into shell
source. For example, on POSIX:
- id: display-result
type: shell
strict: true
env:
RESULT: "{{ steps.plan.output.stdout }}"
run: 'printf "%s\n" "$RESULT"'
For cross-platform scripts, read the environment in the program itself, such
as os.environ["RESULT"] in Python. See
Shell Step Environment Variables for the
binding contract and Interpolation and shell safety
for the remaining trust boundary.
Security note: a
shellstep runs a local command with your privileges. There is no capability sandbox —requiresis an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does not restrict what a step can do. In particular there is norequires.permissionscapability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use agatestep to require explicit approval before sensitive or destructive shell commands.
Shell timeout must be a positive number and defaults to 300 seconds. A timeout kills the command and fails the step. Shell output is captured for later expressions.
Shell Step Environment Variables
Shell steps automatically receive the following environment variables:
| Variable | Description |
|---|---|
SPECKIT_WORKFLOW_DIR |
Resolved absolute path to the workflow source directory (same value as {{ context.workflow_dir }}). Not set when the workflow has no source path. |
An optional per-step env mapping is available in both strict and non-strict
mode. Names must match [A-Za-z_][A-Za-z0-9_]*; names are literal, not
expressions. Values must be strings and may contain workflow expressions.
Each value is evaluated once and must resolve to a string without NUL
characters. On POSIX, the string must also be encodable for the process
environment using the filesystem encoding and its error handler.
Invalid bindings fail the step before launching a process.
Single-expression bindings preserve the expression's result type:
COUNT: "{{ inputs.count }}" rejects a number, boolean, null, list, or mapping
instead of converting it to text. Mixed templates use standard expression
interpolation: COUNT: "n={{ inputs.count }}" produces n=42 for integer
42; embedded null becomes empty text, and other values use their string
representation. To serialize structured data explicitly, use to_json.
Bindings override inherited environment values for that subprocess only;
they do not modify the parent process or subsequent steps. An empty string is
valid. SPECKIT_WORKFLOW_DIR is reserved (case-insensitively) and cannot be
overridden. Keep environment names and executable-loading settings under the
workflow author's control: environment binding is a data channel, not a sandbox.
Reviewing shell execution
Before installing or running an unfamiliar workflow, audit each shell step's
run and env, including nested branches and fan-out templates. Review
referenced scripts and resources, called workflows, and active overlay YAML
that can replace or insert steps. Apply the same review to workflow components
of bundles, and repeat it when definitions, overlays, or referenced code change.
Custom step packages require their own source review because loading them
executes Python code.
An environment value can control execution rather than merely supply data.
For example, PATH selects executables, PYTHONPATH affects Python imports,
BASH_ENV can select Bash startup code, and NODE_OPTIONS can change Node.js
startup behavior. Native loaders and other programs have their own settings;
these examples are not an exhaustive list. A fixed run can therefore execute
different code even with strict: true and no eval. Strict mode neither
filters execution-controlling bindings nor cleans the inherited environment.
Keep those settings literal and author-vetted, or constrain dynamic values to
an approved set of trusted paths/options. Use unconstrained input or agent
output only for application-data variables whose consumers handle it as data.
When a shell step invokes Specify itself, catalog overrides such as
SPECKIT_WORKFLOW_CATALOG_URL can also change where child commands discover
or install components. Vet those bindings alongside the
active catalog sources.
Workflow validation checks configuration, not whether environment settings or
commands are safe. specify workflow info shows metadata and step identities;
specify workflow resolve shows layer and step attribution. Neither displays
the complete run/env configuration or replaces reading the contributing
source files. Catalog install permission, bundle installation, and strict mode
are not security endorsements.
GitHub step
type: github uses the installed gh CLI and its active authentication.
Specify the GitHub repository as owner/repo; no Git remote or source checkout
is required. Requests go to GitHub's api.github.com REST endpoint for that
repository. The active token needs permission to read the issue or pull request
and to write its labels (for example, issues: write for issues or
pull-requests: write for PRs when using GITHUB_TOKEN in Actions). The step
uses only the existing token: it does not request or elevate permissions. It
does not inspect issue contents for instructions or make a maintainer decision.
- id: mark-ready
type: github
operation: add-label
repository: owner/repo
target: issue
number: "{{ inputs.issue_number }}"
label: ready-for-review
target is issue or pull_request; number must resolve to a positive
integer. label is one explicit label name (up to 50 characters) and must
already exist in the repository. repository, number, and label may use
workflow expressions. The repository must resolve to a single owner/repo
name, not a URL or Git remote. The step checks the target type and its existing
labels, skips the write when the label is already present, and verifies GitHub's
response after adding it.
The output includes repository, target, number, label, and added
(true only when a label was added). Repeated execution and fan-out items
are safe to retry: an already-present label is left unchanged. This first
version does not post comments, restore artifacts, check out PRs, or infer
which label a maintainer would choose.
Init Steps
Bootstrap a project the same way specify init does — scaffolding
templates, scripts, shared infrastructure, and the selected coding agent
integration. Runs non-interactively (defaults to --ignore-agent-tools)
and resolves the integration from the step config or the workflow default:
- id: bootstrap
type: init
here: true # or: project: my-project
integration: copilot # Optional: defaults to workflow integration
integration_options: "--skills" # Optional: extra options for the integration
script: sh # Optional: sh, ps, or py
force: true # Optional: required when target directory already exists
preset: healthcare-compliance # Optional preset ID
Workflow Slots
Declare a named workflow slot that downstream projects can fill with a
workflow overlay. The slot is skipped when unfilled; its id is the overlay
anchor and name is a required human-readable label:
- id: post-implement
type: slot
name: "Post-implementation checks"
See Workflow slots for filling a slot, preserving its outputs, and fan-out limitations.
Gate Steps
Prompt for human review on an interactive terminal, or pause for a later
specify workflow resume. A verdict input can decide without prompting:
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, edit, reject]
on_reject: abort
Gate Verdict Inputs
verdict_input binds a gate's verdict to a named workflow input. The input must
be declared in the workflow's inputs block; workflow validation during add
or run reports an undeclared reference.
verdict_input is not supported inside a fan-out template. Fan-out items
share workflow inputs, while workflow state can represent only one paused
gate. Place a gate before the fan-out to approve the whole batch, or after a
fan-in to review the aggregated results.
Input value semantics:
| Value | Behavior |
|---|---|
| Non-empty string, matches an option (case-insensitive) | Gate auto-decides; output.choice is set to the configured option spelling |
| Non-empty string, no match | Gate fails immediately |
| Non-string | Gate fails immediately |
| Missing or empty | Gate prompts on a TTY; pauses otherwise |
Default value semantics: A non-empty default is consumed as a verdict on the first run — matching an option auto-decides the gate, not matching fails it immediately.
inputs:
spec_verdict:
type: string
default: ""
steps:
- id: review-spec
type: gate
message: "Approve the specification?"
options: [approve, reject]
on_reject: retry
verdict_input: spec_verdict
Supply a verdict when resuming:
specify workflow resume <run_id> --input spec_verdict=approve
For on_reject: retry, a bound reject verdict is consumed before the gate
pauses: the named stored input is reset to "". A later resume therefore
prompts or pauses again until another verdict is supplied. Approve, abort, and
skip outcomes leave the input unchanged.
Because of that reset, a verdict input used with on_reject: retry must accept
"". If it declares an enum, include the empty string — otherwise the reset
value violates the input's own enum and the run can no longer be resumed with
any input. specify workflow add reports this as a validation error.
inputs:
spec_verdict:
type: string
enum: ["", approve, reject]
default: ""
If/Then/Else Steps
Conditional branching based on an expression:
- id: check-scope
type: if
condition: "{{ inputs.scope == 'full' }}"
then:
- id: full-plan
command: speckit.plan
else:
- id: quick-plan
command: speckit.plan
options:
quick: true
Switch Steps
Multi-branch dispatch on an expression value:
- id: route
type: switch
expression: "{{ steps.review.output.choice }}"
cases:
approve:
- id: plan
command: speckit.plan
reject:
- id: log
type: shell
run: "echo 'Rejected'"
default:
- id: fallback
type: gate
message: "Unexpected choice"
While Loop Steps
Repeat steps while a condition is truthy:
- id: retry
type: while
condition: "{{ steps.run-tests.output.exit_code != 0 }}"
max_iterations: 5
steps:
- id: fix
command: speckit.implement
Do-While Loop Steps
Execute steps at least once, then repeat while condition holds:
- id: refine
type: do-while
condition: "{{ steps.review.output.choice == 'edit' }}"
max_iterations: 3
steps:
- id: revise
command: speckit.specify
Fan-Out Steps
Dispatch a step template for each item in a collection. The default
max_concurrency: 1 is sequential; higher values run items concurrently,
with independent item contexts and ordered results:
- id: parallel-impl
type: fan-out
items: "{{ steps.tasks.output.task_list }}"
max_concurrency: 3
step:
id: impl
command: speckit.implement
Fan-In Steps
Aggregate results from fan-out steps:
- id: collect
type: fan-in
wait_for: [parallel-impl]
output: {}
wait_for references declared step IDs. For a fan-out, use the fan-out step's
own id (parallel-impl above); its ordered item results are available as
steps.<fan-out-id>.output.results. Generated item aliases such as
fan:template:0 are reporting-only and are not valid wait_for targets.
Workflow composition
A workflow step executes an installed, enabled workflow in the current project
as a private scope of the same run. Targets can be literal IDs or expressions;
the resolved string must match the ID exactly, including case and whitespace.
inputs:
target: {type: string, required: true}
report: {type: string, required: true}
steps:
- id: investigate
type: workflow
workflow: "{{ inputs.target }}"
input:
report: "{{ inputs.report }}"
The included workflow sees only declared inputs passed through input and its
own step results. It does not inherit the caller's inputs, step results,
fan-out item/fan_in values, or workflow defaults. Unknown input names,
missing required values, and invalid types/enums fail the call. Existing child
defaults and integration: auto resolution apply. Values cross back only
through explicit declarations:
outputs:
report:
value: "{{ steps.analyze.output.stdout }}"
The caller reads {{ steps.investigate.output.report }}. Output includes
workflow and status; failures include error when the failed operation
reported one, and an abort includes aborted: true. These names and
integration, model, options, and input are reserved. Returned values
must be JSON-safe. Private inputs, step records, and logs are not part of the
return value. No separate child run is created.
continue_on_error: true on a workflow call handles a returned child failure
and call-boundary contract failures, such as an unavailable target, invalid
mapped input, cycle, depth limit, or invalid declared output. It does not catch
step exceptions, expression errors (including from_json errors in input: or
outputs:), interruptions, or checkpoint failures; those propagate exactly as
they do at the root. An unavailable step implementation is terminal where the
step occurs; at a workflow call it is a reported child failure, whether it is
detected while binding the target or later. Pauses and explicit aborts always
stop execution.
Nested composition is allowed, but repeated workflow IDs on the active call path are cycles. Diamonds are allowed. The maximum included depth is 16, with the root at depth zero.
Calling a Child Gate
Only root-declared inputs can be supplied to workflow resume --input. Map a
root input through each workflow boundary when a child gate uses it as a
verdict_input:
# Parent workflow
inputs:
approval:
type: string
default: ""
steps:
- id: review-release
type: workflow
workflow: release-notes
input:
approval: "{{ inputs.approval }}"
# Installed release-notes workflow
inputs:
approval:
type: string
default: ""
steps:
- id: review
type: gate
message: "Approve the release notes?"
options: [approve, reject]
verdict_input: approval
The initial run pauses if approval is empty. Its structured status identifies
the nested gate and its enclosing call with gate.scope_path. Resume the root
run, not the child, to continue it:
specify workflow status <run-id> --json
specify workflow resume <run-id> --input approval=approve
A gate with verdict_input is not supported inside a fan-out item, including
through one or more workflow calls. The inside_fan_out runtime condition is
preserved across workflow boundaries.
Defaults and Errors at the Call Boundary
Parent integration, model, and options defaults apply only to steps in
the parent workflow. A child uses its own defaults or automatic resolution. To
make a value common to both, declare it as a child input and map it explicitly.
The call result intentionally has an empty input field and does not expose
the child's private inputs, step records, or logs.
An initial binding or output-finalization contract failure can be handled with
continue_on_error. A failure while rebinding an incomplete call during
workflow resume --input is different: it propagates. For a previously bound
call, resume discards the prior result, outcome, and error while
preserving its binding and child subtree, so it can be retried with corrected
root inputs. A call whose initial binding failed returns to ready and retries
target resolution and binding.