Skip to content

Latest commit

 

History

History
1352 lines (1060 loc) · 59.3 KB

workflows.md

File metadata and controls

1352 lines (1060 loc) · 59.3 KB

Workflows

Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, and can be paused and resumed from the exact point of interruption.

Run a Workflow

specify workflow run <source>
Option Description
-i / --input Pass input values as key=value (repeatable)
--json Emit the run outcome as a single JSON object

Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via --input or will be prompted interactively.

Example:

specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management"

Inputs declared as type: number preserve integer values and integer strings exactly, including 64-bit identifiers and nanosecond timestamps above 2**53. Decimal and exponent strings continue to use floating-point conversion; whole-valued results are stored as integers. Booleans, non-numeric values, and non-finite numbers are rejected.

With --json, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted):

specify workflow run my-pipeline.yml --json
{
  "run_id": "662bf791",
  "workflow_id": "build-and-review",
  "status": "paused",
  "current_step_id": "review",
  "current_step_index": 0
}

workflow_id is the workflow.id declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under --json, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.

For failed and aborted runs, the payload includes an error field carrying the terminal step's error message:

{
  "run_id": "662bf791",
  "workflow_id": "build-and-review",
  "status": "failed",
  "current_step_id": "boom",
  "current_step_index": 0,
  "error": "Command exited with code 3"
}

completed and paused runs omit the error field. The error is persisted in the run's state.json, so specify workflow status <run_id> --json surfaces the same message after the fact.

Adapter-load failures before creating or loading run state use a pre-run failure envelope: a new run has no run ID, while resume retains the supplied run ID but has no workflow or step context. After run state exists, adapter failures from per-step reloads or verified lazy imports retain the actual run, workflow, and current-step fields, just like execution I/O failures. If saving state fails, the on-disk status may still reflect the last successful save rather than the reported I/O failure.

Note: Most workflow commands require a project already initialized with specify init. The exception is specify workflow run <local-file.{yml,yaml}>, which can run outside a project; in that case, run state is stored under the current directory's .specify/workflows/runs/<run_id>/.

Resume a Workflow

specify workflow resume <run_id>
Option Description
-i / --input Updated input values as key=value (repeatable)
--json Emit the resume outcome as a single JSON object

Resumes a paused or failed workflow run from its persisted execution state. A running run is not resumable. Resume replays completed work into its expression contexts without running step implementations again, then continues at the unfinished occurrence. This lets a run continue with information that only became available after it paused, or with a corrected value after a failure.

Supplied --input values are merged over the run's stored root inputs and re-validated against the workflow's input types. Unknown root input names are ignored. Updated values affect only unfinished work; completed steps and calls are not reinterpreted or repeated:

specify workflow resume <run_id> --input cmd="exit 0"

Workflow Status

specify workflow status [<run_id>]
Option Description
--json Emit run status (or the runs list) as a JSON object

Shows the status of a specific run, or lists all runs if no ID is given. Run states: created, running, completed, paused, failed, aborted.

List Installed Workflows

specify workflow list

Lists workflows installed in the current project.

Install a Workflow

specify workflow add <source>
Option Description
--dev Install from a local YAML file, package directory, or archive
--from <url> Install from a custom URL (<source> names the expected workflow ID)
--version <version> Install an exact advertised catalog release (<source> must be a workflow ID)

Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a directory containing workflow.yml, or a .zip, .tar.gz, or .tgz archive. Archives may contain workflow.yml at the root or inside one top-level directory.

Directory and archive installs preserve the complete workflow package, including scripts and other companion files. ZIP, .tar.gz, and .tgz archives follow the same validation and installation behavior.

Catalog entries keep the current release's version, url, optional sha256, and optional requires at the top level. An optional releases mapping advertises historical versions without changing what unqualified add, search, info, or update select:

{
  "id": "example",
  "version": "2.0.0",
  "url": "https://example.com/example-2.0.0.zip",
  "releases": {
    "1.0.0": {
      "url": "https://example.com/example-1.0.0.zip",
      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "requires": {"speckit_version": ">=1.0.0"}
    }
  }
}

Each historical release needs its own URL and SHA-256 digest; requires is optional and, when present, must match the downloaded workflow definition. Advertised versions use the workflow definition's X.Y.Z version format; --version also accepts equivalent spellings such as v1.0 when selecting an advertised 1.0.0 release. The requested version must exist in the highest-priority catalog that provides the workflow. A missing version does not fall back to another source, and discovery-only catalogs cannot be installed from. The downloaded workflow ID, version, and declared digest are verified before installation. --version does not apply to local paths, direct URLs, or --from installations.

Workflow Overlays

Workflow overlays let a project extend or override an installed workflow without editing the installed workflow.yml. This keeps local customizations safe across specify bundle update or specify workflow add upgrades.

When specify workflow run <workflow-id> loads a workflow, the engine composes the base workflow with all enabled overlays for that workflow id. The result is validated like any other workflow definition.

How Overlays Work

An overlay is a YAML file that declares a set of edit operations against the step list of a base workflow. Overlays use lower-wins precedence: higher priority numbers are applied first and lower numbers last. Equal-priority overlays are applied alphabetically by ID, with the last ID winning conflicts.

Project overlay files live at:

Location Purpose
.specify/workflows/overlays/<id>/*.yml Project-local customizations

Overlay File Format

The recommended edit format uses the operation name as the key and the anchor step id as the value:

id: "my-overlay"
extends: "speckit"
priority: 10
enabled: true
edits:
  - insert_after: implement
    step:
      id: run-lint
      type: shell
      run: "ruff check src/"

  - replace: review-spec
    step:
      id: review-spec
      type: gate
      message: "Review the generated spec (overlay override)."
      options: [approve, reject]
      on_reject: abort

The explicit form is also supported:

edits:
  - operation: insert_after
    anchor: implement
    step:
      id: run-lint
      type: shell
      run: "ruff check src/"

Fields

Field Required Description
id yes Identifier for this overlay. Used in specify workflow overlay * commands. Must be lowercase letters, digits, and hyphens only; no dots, underscores, path separators, or overlays.
extends yes The workflow id this overlay applies to. Uses the same safe-id format as id; overlays, runs, and steps are reserved.
priority no Integer; defaults to 10. Lower values have higher precedence and win conflicts. Missing or invalid values fall back to 10.
enabled no Boolean. Defaults to true. Disabled overlays are ignored.
edits yes Non-empty list of edit operations.

Edit Operations

Operation step required Effect
insert_after yes Insert step immediately after the anchor step.
insert_before yes Insert step immediately before the anchor step.
replace yes Replace the anchor step with step.
remove no Remove the anchor step from the list.

The anchor is the id of a step in the base workflow. Anchors are resolved recursively inside then, else, steps, cases.*, and default blocks, so nested base steps can also be targeted. Fan-out templates (step inside a fan-out step) are not valid anchors.

Step ids must not contain : — that character is reserved for engine-generated nested ids.

Overlay CLI Commands

Add a Project Overlay

specify workflow overlay add <path-to-overlay.yml> --priority <n>

Validates the overlay file and copies it to .specify/workflows/overlays/<extends>/<id>.yml. --priority defaults to 10 and overrides the priority field in the file.

List Overlays

specify workflow overlay list <workflow-id>

Shows all overlays for the workflow, ordered by resolver precedence. Disabled overlays are marked as disabled in the listing and are ignored during workflow resolution.

Change Priority

specify workflow overlay set-priority <workflow-id> <overlay-id> <n>

Enable or Disable

specify workflow overlay disable <workflow-id> <overlay-id>
specify workflow overlay enable <workflow-id> <overlay-id>

Remove

specify workflow overlay remove <workflow-id> <overlay-id>

Removes the project overlay file.

Inspect the Composed Workflow

specify workflow resolve <workflow-id>

Prints the layer stack (base + overlays) and the source attribution for each step after composition. Useful for debugging which overlay contributed or overrode a step.

Example: Adding Automated Linting after Implementation

Given the built-in speckit workflow, create project-overlay.yml:

id: "add-lint"
extends: "speckit"
priority: 10
edits:
  - insert_after: implement
    step:
      id: run-lint
      type: shell
      run: "ruff check src/"

Install it:

specify workflow overlay add project-overlay.yml --priority 10

Run the workflow:

specify workflow run speckit -i spec="Build a kanban board"

The composed workflow will now run the full SDD cycle and execute ruff check src/ automatically after the implement step.

Example: Replacing a Gate

id: "skip-plan-review"
extends: "speckit"
priority: 5
edits:
  - replace: review-plan
    step:
      id: review-plan
      type: command
      command: speckit.plan
      input:
        args: "{{ inputs.spec }}"

Lower priority values have higher precedence. Change this overlay to priority: 5 if it must win a conflict with the add-lint overlay above. It replaces the review-plan gate with a non-interactive command.

Workflow slots (upstream extension points)

Workflow authors can declare a named, no-op workflow slot with type: slot:

- id: post-implement
  type: slot
  name: "Post-implementation checks"

The step id is the unique overlay anchor; name is a required non-blank, human-readable label only. An unfilled slot completes as a skipped step with output: {slot: <name>}, so subsequent steps continue normally.

Fill a slot with a schema-valid overlay replace edit anchored on the step id, not its name:

id: fill-post-implement
extends: my-workflow
edits:
  - replace: post-implement
    step:
      id: post-implement
      type: shell
      run: "echo Run project-specific checks"

Reuse the slot's id when later expressions or fan-in.wait_for refer to it. The replacement must also preserve every output key those later steps consume: an unfilled workflow slot supplies only steps.<id>.output.slot. Slot steps are not supported inside fan-out.step templates because runtime-multiplied templates cannot be overlay anchors.

Interaction with Bundles and Updates

specify workflow add <local-directory> installs the complete local workflow package into .specify/workflows/<id>/. Archive installs preserve the same package contents.

When an installed workflow is refreshed or reinstalled, project overlays in .specify/workflows/overlays/<id>/ are preserved because they live outside the installed workflow directory.

Limitations

  • Overlays operate on the step list only. They cannot change workflow metadata (name, description, inputs, requires) or expression logic.
  • Fan-out templates cannot be used as anchors.
  • An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
  • Overlays cannot target steps added by other overlays.
  • Overlays cannot add new inputs or change the input schema of the base workflow.

Update Workflows

specify workflow update [workflow_id]

Updates one installed catalog workflow — or all of them when no ID is given — to the latest catalog version. Prompts for confirmation and keeps the installed copy if a download or validation fails.

Enable or Disable a Workflow

specify workflow enable <workflow_id>
specify workflow disable <workflow_id>

Disabled workflows stay installed and listed (marked [disabled]) but refuse to run until re-enabled.

Remove a Workflow

specify workflow remove <workflow_id>

Removes an installed workflow from the project.

Search Available Workflows

specify workflow search [query]
Option Description
--tag Filter by tag
--author Filter by author

Searches all active catalogs for workflows matching the query.

Workflow Info

specify workflow info <workflow_id>
specify workflow info <workflow_id> --versions

Shows detailed information about a workflow, including its steps, inputs, and requirements. --versions lists the current catalog version followed by advertised historical versions and indicates whether the winning catalog is installable or discovery-only (not installable). It also works when a different version is installed locally.

Catalog Management

Workflow catalogs control where search and add look for workflows. Catalogs are checked in priority order.

Workflow and step catalogs cache fetched JSON and freshness metadata for one hour in .specify/workflows/.cache/ and .specify/workflows/steps/.cache/, respectively. Cache I/O does not follow symlinked directory components or cache files. Writes atomically replace cache files rather than truncate their referents, so a pre-existing cache-file symlink can be replaced without changing its target. If a cache cannot be safely written, a warning is logged and discovery continues using the fetched catalog.

A project's .specify/workflow-catalogs.yml can point add and search at a catalog you didn't choose. Before running a workflow from an unfamiliar project, run specify workflow catalog list (and specify workflow step catalog list for the step catalogs its steps can pull in) — a project supplying that config is not evidence its workflows or steps were vetted. Maintainers do not audit run fields; read a workflow's shell steps yourself before running it (see Who maintains workflows?).

List Catalogs

specify workflow catalog list

Shows all active catalog sources.

Add a Catalog

specify workflow catalog add <url>
Option Description
--name <name> Optional name for the catalog

Adds a custom catalog URL to the project's .specify/workflow-catalogs.yml.

Re-adding the same workflow or step catalog URL with the same name succeeds without changing the configuration; a different name is rejected.

Remove a Catalog

specify workflow catalog remove <index>
specify workflow step catalog remove <index>

Removes a project catalog by its index in the corresponding priority-ordered catalog list. Sources supplied by SPECKIT_WORKFLOW_CATALOG_URL or SPECKIT_STEP_CATALOG_URL cannot be removed this way; unset the variable to manage project sources.

Catalog Resolution Order

Catalogs are resolved in this order (first match wins):

  1. Environment variable — SPECKIT_WORKFLOW_CATALOG_URL overrides all catalogs
  2. Project config — .specify/workflow-catalogs.yml
  3. User config — ~/.specify/workflow-catalogs.yml
  4. Built-in defaults — official catalog + community catalog

Workflow Definition

Workflows are defined in YAML files. Here is the built-in Full SDD Cycle workflow that ships with Spec Kit:

schema_version: "1.0"
workflow:
  id: "speckit"
  name: "Full SDD Cycle"
  version: "1.0.1"
  author: "GitHub"
  description: "Runs specify → plan → tasks → implement with review gates"

requires:
  speckit_version: ">=0.8.5"
  integrations:
    any:
      - "alquimia"
      - "claude"
      - "copilot"
      - "gemini"
      - "opencode"

inputs:
  spec:
    type: string
    required: true
    prompt: "Describe what you want to build"
  integration:
    type: string
    default: "auto"
    prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"

steps:
  - id: specify
    command: speckit.specify
    integration: "{{ inputs.integration }}"
    input:
      args: "{{ inputs.spec }}"

  - id: review-spec
    type: gate
    message: "Review the generated spec before planning."
    options: [approve, reject]
    on_reject: abort

  - id: plan
    command: speckit.plan
    integration: "{{ inputs.integration }}"
    input:
      args: "{{ inputs.spec }}"

  - id: review-plan
    type: gate
    message: "Review the plan before generating tasks."
    options: [approve, reject]
    on_reject: abort

  - id: tasks
    command: speckit.tasks
    integration: "{{ inputs.integration }}"
    input:
      args: "{{ inputs.spec }}"

  - id: implement
    command: speckit.implement
    integration: "{{ inputs.integration }}"
    input:
      args: "{{ inputs.spec }}"

This produces the following execution flow:

flowchart TB
    A["specify<br/>(command)"] --> B{"review-spec<br/>(gate)"}
    B -- approve --> C["plan<br/>(command)"]
    B -- reject --> X1["⏹ Abort"]
    C --> D{"review-plan<br/>(gate)"}
    D -- approve --> E["tasks<br/>(command)"]
    D -- reject --> X2["⏹ Abort"]
    E --> F["implement<br/>(command)"]

    style A fill:#49a,color:#fff
    style B fill:#a94,color:#fff
    style C fill:#49a,color:#fff
    style D fill:#a94,color:#fff
    style E fill:#49a,color:#fff
    style F fill:#49a,color:#fff
    style X1 fill:#999,color:#fff
    style X2 fill:#999,color:#fff
Loading

Run it with:

specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management"

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

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.

Execution identity and resume

Each step occurrence owns a record in a persisted execution tree. An authored step ID is a local expression alias, not a global execution ID. Fan-out items have independent alias contexts and ordered item results. Public reporting uses qualified occurrence IDs where needed, for example fan:template:0 for a fan-out item and loop:step:1 for a later loop iteration. Qualified IDs are not expression names. Fan-out item aliases are reporting-only: a fan-in wait_for references declared step IDs, in particular the fan-out step's own id, whose ordered item results are available as steps.<fan-out-id>.output.results.

Migration note: a fan-out's output no longer includes step_template. steps.<fan-out-id>.output and the fan-out's entry in a fan-in's output.results contain items, max_concurrency, item_count, and results; the template is kept only in the private execution tree that runs the items. An expression such as {{ steps.<fan-out-id>.output.step_template }} now resolves to empty. Read the template from the fan-out's step in the workflow definition instead.

New runs persist selected branches, dynamic custom-step expansions, loop iterations, and fan-out items. Resume retains completed work without reevaluating already selected branches. Workflow targets and overlay-resolved definitions remain bound even if installations change. An unbound call still checks that its target is installed and enabled when execution reaches it.

Ordinary resume retains bound inputs. Explicit --input rebinds reached, incomplete calls through their original mappings: newly mapped values override the prior binding, while values not mapped again retain their bound values. Completed calls retain their results. Failed output evaluation retries only finalization, without repeating completed child commands.

Snapshots are stored as YAML strings inside the private JSON execution tree, preserving YAML scalar types. Inputs and results remain JSON values. The snapshot freezes workflow definitions and expansions, not files under context.workflow_dir or step implementations; missing resources can still cause ordinary step failures. Legacy runs without a tree enter through their saved root index, then use tree-backed resume. Resume is available only for runs in the paused or failed state.

current_step_index is the root-sequence index; current_step_id is a compatibility occurrence-ID view updated as steps are entered. It is not an exact active-step set: during parallel fan-out it may name an item that has already completed. Qualified values include fan:item:0 for a fan-out item and loop:body:1 for a later loop iteration. Inside a workflow call, IDs are relative to the called workflow. Structured run/status output includes workflow_scopes summaries when calls exist and reports an active nested gate with its scope_path. Private log events add workflow_id and execution_path to the qualified step_id. A step emits completion after its checkpoint; containers emit before their children, while replayed completed occurrences emit no step events.

Security note: a shell step runs a local command with your privileges. There is no capability sandbox — requires is 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 no requires.permissions capability 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 a gate step to require explicit approval before sensitive or destructive shell commands.

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.

Custom step packages

Custom step types are installed with specify workflow step. A step is a directory package containing metadata and executable Python:

my-step/
├── step.yml        # required, at the package root
├── __init__.py     # required, at the package root
└── helpers.py      # optional nested modules and data files

To prepare a public package for community catalog intake, see Community Workflow Step Types.

step.yml declares the step's identity. step.type_key must exactly match the <step_id> passed on the command line — the ID is never inferred from package content:

step:
  type_key: my-step
  name: My Step
  version: 0.1.0
  author: you
  description: What this step does

__init__.py must define a StepBase subclass whose type_key matches:

from specify_cli.workflows.base import StepBase, StepResult


class MyStep(StepBase):
    type_key = "my-step"

    def execute(self, config, context):
        return StepResult(output={"ok": True})

Install from a local directory

specify workflow step add my-step --dev /path/to/my-step

--dev takes a directory (not an archive, not a bare step.yml) that is a complete package. This needs no catalog, server, or network, which makes it the supported local-authoring loop:

specify workflow step add my-step --dev ./my-step
specify workflow step list
specify workflow step info my-step
# edit ./my-step, then replace the installed copy:
specify workflow step add my-step --dev ./my-step --force
specify workflow step remove my-step

Install from an archive URL

specify workflow step add my-step --from https://example.com/my-step.zip

--from accepts a .zip, .tar.gz, or .tgz archive (a bare step.yml URL is not a package). The archive may place step.yml and __init__.py at its root or under exactly one top-level directory; unrelated top-level siblings are rejected. Because a step package contains executable Python, a direct URL install shows a default-deny trust confirmation before any network request; declining cancels with no request and no error. HTTPS is required (HTTP is permitted only for loopback hosts), redirects must remain secure, and downloads are size-bounded.

Install from the catalog

specify workflow step add my-step

Catalog installs resolve individual file URLs from the active step catalogs and then go through the same validation and commit path as --dev and --from. Discovery-only catalogs cannot be installed from.

To submit a custom step type to the discovery-only community catalog, follow Community Workflow Steps. Submissions use tag-pinned individual file URLs and per-file digests; maintainers validate metadata and distribution evidence, not submitted code.

Catalog release history

specify workflow step info <id> --versions lists the current and historical releases in the winning catalog. specify workflow step add <id> --version <v> selects the exact catalog release; without --version, add installs the advertised current release. The requested version never falls back to a lower-priority catalog, and discovery-only sources remain non-installable. --version cannot be combined with direct --dev or --from installs.

Existing single-version catalog entries continue to work. Keep the current release's version, step_yml_url (or url), init_url, and optional files at the top level. An optional releases mapping adds historical versions:

{
  "steps": {
    "my-step": {
      "version": "2.0",
      "step_yml_url": "https://example.com/my-step/2.0/step.yml",
      "releases": {
        "1.0": {
          "step_yml_url": "https://example.com/my-step/1.0/step.yml",
          "init_url": "https://example.com/my-step/1.0/__init__.py",
          "extra_files": {"helper.py": "https://example.com/my-step/1.0/helper.py"},
          "sha256": {
            "step.yml": "<64 hex digits>",
            "__init__.py": "<64 hex digits>",
            "helper.py": "<64 hex digits>"
          }
        }
      }
    }
  }
}

Each historical release must supply its own step URL and SHA-256 digest for every downloaded file. init_url can be omitted when derived from a step_yml_url ending in step.yml. To select the current version explicitly, its top-level entry must likewise supply the per-file sha256 mapping. Release-specific files and requirements are not inherited from the current release. Repeated or malformed versions are rejected; equivalent PEP 440 spellings such as v1.0 and 1.0 select the same advertised release. The downloaded step.yml must declare the selected step ID and version. An unqualified legacy catalog install does not require digests or a step version. Only one version of each step ID can be installed at a time; use --force to replace a previous installation after reviewing the selected package.

Replacement and force

specify workflow step add my-step --dev ./my-step --force
specify workflow step add my-step --from https://example.com/my-step.zip --force

--force first stages and validates the replacement before touching the existing installation, and can replace both a registered install and a leftover unregistered directory. Validation and staging failures leave the previous package untouched. If removing the old directory fails, the replacement is not published. If publishing the replacement or updating the registry fails after the old directory has been removed, the installation may be left incomplete: rerun the command with the original source and --force to reinstall. No automatic rollback is attempted.

Package validation

Every source is validated identically before anything is committed:

  • step.yml and __init__.py must be regular, non-symlink files at the package root.
  • The package tree is copied recursively (relative imports, nested helper modules, and data files are supported). A symlinked package root, any descendant symlink, and any filesystem object that is not a regular file or directory are rejected.
  • .git, __pycache__, and .DS_Store entries are skipped without being inspected: they are not copied, excluded directories are not entered, and they do not count toward any limit.
  • The installed-package policy permits at most 512 retained entries (files and directories combined), at most 32 levels of directory nesting, and 50 MiB of retained content.
  • Archive URLs also pass transport/extraction safety limits before package validation: at most 512 archive entries, 50 MiB downloaded or extracted, and 10 MiB per archive member. Catalog files have a 50 MiB per-response bound.
  • Installation validates and copies the package but does not import or execute __init__.py. Installed custom step modules are loaded during startup of workflow add, workflow run, and workflow resume, before any particular custom step necessarily executes.

Security note: Loading a custom step runs its Python with your privileges. Only install and retain step packages from sources you trust.

Listing, running, and removing

Installed custom steps appear in specify workflow step list and are loaded automatically by workflow add, workflow run, and workflow resume. Remove one with:

specify workflow step remove my-step

Registry provenance

Each installed step records only the kind of its source — catalog (optionally with the catalog name), local, or url. Local paths and source URLs are never persisted. specify workflow step info <id> shows the source.

Bundle-local limitation

A bundle's provides.steps still resolves only through the active step catalogs. Bundle-local steps/<id>/ payloads and relative provides.steps[].source overrides are not resolved in this release, so such steps are not installable offline. See the Bundles reference.

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.

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.json so that specify workflow resume can 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 to log.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.

Expressions

Steps can reference inputs and previous step outputs using {{ expression }} syntax:

Namespace Description
inputs.spec Workflow input values
steps.specify.output.file Output from a previous step
item Current item in a fan-out iteration
context.run_id Current workflow run ID
context.workflow_dir Resolved absolute path to the workflow source directory. Empty string for string-loaded workflows.

Available filters: default, join, contains, map, from_json, to_json, upper, lower, split, length.

Filter Example Behavior
default {{ val | default('fb') }} Fallback for None or an empty string
join {{ list | join(', ') }} Join list elements into a string
contains {{ text | contains('sub') }} Substring or membership check
map {{ list | map('attr') }} Extract an attribute from each item
from_json {{ out | from_json }} Parse a JSON string into a typed value
to_json {{ obj | to_json }} Serialize a value to a JSON string — the inverse of from_json; mapping keys must be strings
upper {{ text | upper }} Uppercase a string
lower {{ text | lower }} Lowercase a string
split {{ csv | split(',') }} Split a string on a separator into a list of strings
length {{ items | length }} Number of elements in a list, or characters in a string

default falls back only for None and the empty string. Other falsy values — 0, false, [], {} — are passed through unchanged, so {{ count | default(10) }} still yields 0 for a zero count. Falsy is not the same as empty here.

Notes on the newer filters:

  • Types are validated, not coerced. upper and lower accept strings only, split requires both a string value and a non-empty string separator, and length accepts lists and strings but rejects mappings. Anything else raises a ValueError naming the problem. Coercion is deliberately not performed: a type mismatch nearly always means the workflow is wired to the wrong variable, and a coerced result would hide that. A filter given the wrong number of arguments (| upper('x'), | split with no separator, | split(',', 1)) is reported as a known filter misused, which is distinct from an entirely unknown filter name: a call carrying more than one argument falls through to that same unsupported-form error rather than being evaluated as a single expression. The older filters (join, map, contains) are more permissive and unchanged: join stringifies unsupported values and map/contains return fallbacks rather than raising.

  • to_json output is deterministic. Object keys are sorted and non-ASCII characters are left as-is rather than escaped, so the same value always serializes to the same bytes. That buys reproducibility only — it does not make the result safe to pass through a shell, because interpolation adds no quoting or escaping. Do not interpolate unconstrained JSON into a run field.

  • to_json rejects non-finite floats. NaN, Infinity, and -Infinity raise a ValueError naming to_json instead of serializing to bare tokens. None of the three is valid JSON, so emitting them would hand a standards-compliant downstream parser a string it must reject.

  • to_json requires string mapping keys. JSON objects have string keys, so a mapping with any other key type — {1: "a"}, {True: "a"} — raises a ValueError naming the key type instead. Left to json.dumps, an integer key would be coerced to "1" and collide with an existing "1" key, while mixed key types would fail sort_keys with an ordering TypeError reported only as "not JSON-serializable".

  • Trailing comparisons after a filter are rejected. The parser splits on the top-level | before looking for operators, so {{ items | length > 0 }} raises rather than evaluating. The count does not exist until length runs, so there is no way to write that comparison; the supported branching form is the filter's own truthiness in a condition:, since length returns 0 for an empty input:

    condition: "{{ inputs.items | length }}"   # 0 is False, any non-zero count is True

and and or return true or false and evaluate their right side only when the left side does not decide the result: and skips it when the left side is false, and or skips it when the left side is true. A condition can therefore guard a filter that would fail on an empty value. {{ inputs.payload != '' and (inputs.payload | from_json | length) }} is false for an empty optional payload instead of failing the run on invalid JSON. When the right side is evaluated, its errors still fail the run.

Example:

condition: "{{ steps.test.output.exit_code == 0 }}"
args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
tag_count: "{{ inputs.tags | split(',') | length }}"
shell_flag: "{{ inputs.branch | upper }}"

Interpolation and shell safety

Expressions are resolved by plain string substitution — the value of {{ ... }} is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building args and message strings, but it has an important consequence for shell steps: a run field is handed to the system shell (/bin/sh -c on POSIX), so any interpolated value is interpreted as shell syntax, not just data.

If an interpolated value can contain characters like ;, |, &, $( ), backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:

  • Workflow inputs.* — supplied by whoever runs the workflow.
  • A prior step's output, e.g. {{ steps.plan.output.stdout }} — for a command or prompt step with capture: true this is text produced by the AI agent, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a shell step.

There is no shell-escaping filter in the expression language and no sandbox around a shell step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value can be, and to keep values you cannot constrain out of run fields entirely. Scrutinise every run field that interpolates a value you do not control, and at minimum:

  • Constrain the value at the source with enum/an allowlist. When inputs.* feeds a run field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.

    inputs:
      target:
        type: string
        enum: [staging, production]   # caller cannot inject arbitrary text
  • Keep unconstrained values out of run. If a value cannot be constrained to an allowlist — most agent/prompt output — do not interpolate it into a run field. Branch on it with if/switch against fixed conditions, or act on it in a command/prompt step rather than a shell command built from it.

  • Quoting is not a security boundary. Surrounding a substitution with quotes ('{{ inputs.x }}') helps the shell treat a trusted value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an unconstrained substitution safe.

  • Gates do not inspect the next step, and message is printed verbatim. A gate step renders only its own message/show_file — it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do not interpolate raw untrusted data into message: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep message to trusted, constrained text, and surface untrusted material for review via show_file instead — its path and contents are control/ANSI-stripped before display.

A shell step is an arbitrary-command primitive by design; these practices reduce exposure and keep which command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.

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.

Input Types

Type Coercion
string Pass-through
number "42" → 42, "3.14" → 3.14
boolean "true" / "1" / "yes" → True

State and Resume

Each workflow run persists its state at .specify/workflows/runs/<run_id>/:

  • state.json — current run state and step progress, including each step's output. Output captured with capture: true is stored here in plain text.
  • inputs.json — resolved input values
  • log.jsonl — step-by-step execution log

This enables specify workflow resume to replay completed occurrences and continue at the unfinished occurrence where a run paused (for example, at a gate) or failed.

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; specify workflow validate 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: ""

FAQ

What happens when a workflow hits a gate step?

The workflow pauses and waits for human input. Run specify workflow resume <run_id> after reviewing to continue.

Can I run the same workflow multiple times?

Yes. Each run gets a unique ID and its own state directory. Use specify workflow status to see all runs.

Who maintains workflows?

Most workflows are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support workflow code. Review a workflow's source before installing and use at your own discretion.