// docs/guides/workflows.md docs online

Writing workflows

A workflow is a YAML file describing an ordered list of steps. Every task runs exactly one, in its own git worktree on its own branch, and the daemon is what runs it.

This page is the practical guide: what to write, in what order, and why the schema is shaped the way it is. Keep the Workflow schema beside it when you need every field and default in table form.

Five ready-to-copy files live in examples/, and the binary will install one for you:

vincent workflow init my-flow --from feature-pr   # or drop --from for a skeleton

If you would rather read one working workflow than a guide, start with feature-pr.yaml and come back here for the parts you want to change.

Agent-assisted authoring

The portable Vincent Workflows skill helps supporting coding agents design and validate these files:

npx skills add lezli01/vincent --skill vincent-workflows -g

It asks only for decisions that change the workflow, including possible human gates and interaction needs. It prefers deterministic command steps and native control flow, and reserves prompt-based agent steps for work that actually requires reasoning.


Contents

Getting started

The file

Making steps talk to each other

Making it reliable

Shipping


Getting started

1. Before you start

1.1 What a workflow is

A workflow is a recipe, not a script. It describes steps; the daemon decides when they run, retries them, times them out, records every attempt, and survives its own restart mid-step. You write what should happen; vincent owns how.

Three properties are worth internalising before you write one, because the schema follows from them:

  1. One task, one worktree, one branch. Every step of a run sees the same working tree, and what a step leaves on disk is what the next step finds. State flows through the filesystem and through {{.Steps}}, never through a shared agent conversation.
  2. Every agent step is a fresh session. Nothing is resumed between steps or between attempts. If step 3 needs to know what step 1 found, step 1’s result has to be in the prompt — see §5.3.
  3. A step that runs out of attempts blocks the task. Nothing is silently abandoned; a human is asked. That is why check: (§7) is worth writing: it decides what “the step worked” means.

1.2 Where workflow files live

Scope Location Precedence
Project .vincent/workflows/*.yaml inside the repo Highest — shadows global
Global {config_dir}/workflows/*.yaml Shadows built-in
Built-in adhoc and create-workflow — one agent step each, always present Lowest

{config_dir} is %APPDATA%\vincent on Windows, ~/Library/Application Support/vincent on macOS and ~/.config/vincent on Linux; the full table is in Files and directories.

Details that matter in practice:

  • Both .yaml and .yml are read, from the directory itself — not recursively, so a subdir/ inside workflows/ is ignored.
  • Only regular files, up to 1 MiB. A symlink, named pipe, socket or device named *.yaml is not a workflow source and is never opened or followed, and a file over 1 MiB is not read whole. Either one is listed as an invalid entry saying why, exactly like a file that fails to parse — the rest of the scope keeps working. Both bounds are fixed; there is no setting for them.
  • Workflows are addressed by their name: field, not by filename. A file named ci.yaml declaring name: feature-pr is the workflow feature-pr. Keeping the two the same is a convention worth following, not a rule.
  • Shadowing is per name. A project file named adhoc replaces the built-in for that project only.
  • create-workflow writes the other kind of file. It is the second built-in: one agent step that designs a workflow from the task’s description and installs it under the name its required workflow_name field gives, guided by the vincent-workflows skill, which is built into its prompt. It may stop and ask a design question it cannot settle from the repository — the task parks in awaiting_input until you answer, so check on it rather than queueing it and walking away. Its boolean task field global picks the registry — unset or false writes {repo}/.vincent/workflows for the task’s own project, true writes {config_dir}/workflows. Either way the file lands in the live registry rather than in the task’s worktree, so the daemon reloads it immediately and it is not part of the task’s diff.
  • Two files in one scope declaring the same name: is an error, resolved deterministically: the first in filename order keeps the name, and the loser is listed as invalid rather than silently dropped.

vincent workflow ls prints the merged registry with a scope badge per entry; --project <id> includes that repository’s own files.

Getting a file into one of those directories. vincent workflow init <name> writes one and prints the path — the skeleton by default, or a shipped example with --from, embedded in the binary so it works from any directory. The default (global) scope needs no daemon; --project <id> does, because only the daemon knows where that repository is. It never overwrites: an existing path, or a sibling in the same scope already declaring that name:, is refused. Taking a name from a lower scope is the shadowing above working as intended, so it warns and writes.

That is the offline route: instant, free, and always the same file. create-workflow above is the other one — it designs a workflow from a description, at the cost of a daemon, an agent CLI, tokens, wall-clock time and a run that may stop to ask you a design question. Reach for init when you know roughly what you want to write.

1.3 Validating a file

vincent workflow validate .vincent/workflows/feature-pr.yaml

This runs entirely locally: no daemon, no network, no agent CLI installed. That is deliberate, so it works in a pre-commit hook and in CI. It exits 1 on an invalid file and prints line N: path: message for each finding.

Two things it uses local stand-ins for, because it cannot ask a daemon:

  • Agent catalogs are the curated, compiled-in ones rather than whatever a live claude --help reports. Probing only ever adds values, so a verdict can soften on a real daemon but never harden.
  • The loop ceiling is the built-in default (10), not your config.yaml. A workflow that validates on your laptop has to validate on a CI runner with no config file at all.

Add --json for machine-readable findings.

1.4 Live reload

The daemon watches both workflow directories and reloads on save. There is no restart and no “apply” step.

  • A file that fails to parse is reported as invalid and kept visible; the previously loaded version keeps running.
  • Editing a workflow never affects a task already created from it. A task captures the file’s source as its snapshot at creation, so an in-flight or historical run is immutable. Fixing a file fixes the next task — to fix a blocked one, use edit + retry, which rewrites the step inside that task’s own snapshot.

2. Your first workflow

2.1 The minimum viable file

vincent workflow init tidy writes a commented one-agent-step starting point into {config_dir}/workflows/tidy.yaml and prints the path. What follows is smaller still, to show what is actually required:

name: tidy
description: Run the formatter and commit the result.

steps:
  - id: format
    type: command
    run: gofmt -w .

  - id: commit
    type: command
    run: 'git add -A && git commit -m "chore: gofmt" --allow-empty'

That is a valid workflow: a name, and a non-empty steps list where every step has a unique id and a type. Save it as .vincent/workflows/tidy.yaml and it is available to that repo’s tasks within a second.

2.2 Add a check

An agent step succeeds when its process exits 0 and its event stream reports no error. That is the agent’s claim about its own work. A check: turns it into something a machine verifies:

name: tidy
description: Fix the lint findings, and prove it.

steps:
  - id: fix
    type: agent
    prompt: |
      Fix every finding reported by `golangci-lint run` in this repository.
      Do not disable a linter or add a nolint directive to silence one.
    check: go build ./... && golangci-lint run

  - id: commit
    type: command
    run: 'git add -A && git commit -m "fix: lint findings" --allow-empty'

If the check fails, the attempt fails, and the retry gets the failure appended to its prompt automatically. That loop — write, verify, be told exactly what broke, try again — is most of the value of running an agent under a workflow rather than by hand.

2.3 Add a gate

Anything that leaves the machine belongs behind a human:

  - id: review
    type: manual
    instructions: |
      Read the diff for task #{{.Task.ID}} on branch {{.Task.BranchName}}.
      Approve to push.

  - id: publish
    type: command
    run: git push -u origin {{.Task.BranchName}}
    max_retries: 0

The task parks in awaiting_gate and releases its concurrency slot while it waits, so a workflow waiting on you is not occupying a runner.

2.4 Run it

vincent workflow validate .vincent/workflows/tidy.yaml
vincent task add --project 1 --workflow tidy --title "Clear the lint backlog"

or press n in the TUI and pick it from the workflow list. Follow it with vincent task show <id>, or open the TUI’s detail view for live output.


The file

3. File anatomy

3.1 Top-level keys

name: feature-pr                 # required — how tasks refer to it
description: One line, shown in the picker.
platforms: [posix]               # optional — where this file may run (§10.3)

fields:                          # optional — ordered task inputs (§5.4)
  - name: ticket
    label: Ticket
    required: true
    pattern: '^OPS-[0-9]+$'

defaults:                        # optional — inherited by every step
  agent: claude
  max_retries: 2
  timeout: 45m

steps:                           # required, non-empty — runs top to bottom
  - id: implement
    type: agent
    prompt: |
      Implement {{.Task.Title}}.
Key Required Notes
name Unique per scope. No whitespace and no / or \
description   One line; shown in the TUI picker and vincent workflow ls
platforms   Empty means anywhere — §10.3
fields   Expected task inputs — §5.4
defaults   Workflow-wide fallbacks — §3.2
steps Runs strictly in order

Unknown keys are errors, not ignored. A misspelled max_reties: fails validation instead of becoming a setting that silently never applied. The same is true of a field on the wrong step type: run: on an agent step is an error, not a hint.

3.2 defaults

defaults: holds workflow-wide fallbacks. Every key in it is also settable per step, where the step wins.

Key Applies to Falls back to
agent agent steps the daemon’s configured default
model agent steps the adapter’s own default
effort agent steps the adapter’s own default (cursor has none)
permission_mode agent steps full-auto
on_input agent steps wait
input_timeout agent steps defaults.input_timeout in config — 24h
max_retries all steps 1
retry_backoff all steps 0s — an immediate retry (§8.2)
timeout all steps defaults.agent_timeout (60m) or defaults.command_timeout (15m) in config

Durations are Go duration strings: 90s, 45m, 1h30m. A bare number is a validation error.

Three deliberate absences:

  • check is not in defaults. A check is specific to what a step produced; a workflow-wide one would run after steps it makes no sense for.
  • shell and env are not in defaults. They belong to command steps, and a file-wide shell pin hides which steps actually need it.
  • allow_failure is not in defaults (§6.2). A file-wide “failures do not block” is a footgun; it should cost one line per step that wants it.

3.3 Steps and ids

Every step needs an id and a type. Ids are slugs: lowercase letters, digits, -, _ and ., starting with a letter or digit.

Ids are the file’s public surface — they are how {{.Steps}} addresses a result, how a transcript file is named, and how the TUI labels a row. Two rules follow:

  • Ids are unique across the whole file, including sub-steps of a parallel group and body steps of a loop. Those share their structure step’s index and are told apart by id alone.
  • A lane’s inline steps have their own namespace, because each lane becomes a separate task with its own flat snapshot (§4.6).

name: is an optional display name and defaults to the id.

3.4 What a step inherits

Reading top to bottom, a value on an agent step resolves like this:

step field  →  task-level override (chosen at creation)  →  defaults:  →  adapter default

with one refinement: model and effort only inherit from a level whose resolved agent matches the step’s. Switching agent without setting them resets them to the new adapter’s default rather than leaking a claude alias onto a codex step. The full rules are in §9.1.


4. The nine step types

4.1 Choosing a step type

You want to… Type
Have an agent write, edit or review code agent
Run a build, test, lint or git command command
Stop and wait for a person manual
Run several independent verifications at once, in one worktree parallel
Split one deliverable into disjoint pieces, each on its own branch fan_out
Finish the run early, successfully condition
Repeat a sequence until it converges, or once per item loop
End the enclosing loop break
Reuse another workflow’s steps here include

check is a field on agent and command steps, not a type of its own (§7). Four types — parallel, fan_out, loop and condition/break — are structure: they organise other steps rather than running anything themselves. include is a fifth kind of thing again: it is resolved away before the task runs.

4.2 agent

Runs an agent CLI headlessly in the worktree. Requires prompt.

  - id: implement
    type: agent
    agent: claude                # else defaults.agent, else the daemon default
    model: sonnet                # optional; adapter-native id or alias
    effort: high                 # optional; cursor has no effort concept
    permission_mode: full-auto   # full-auto (default) | restricted
    on_input: wait               # wait (default) | deny | require
    prompt: |
      Implement {{.Task.Title}}.

      {{.Task.Description}}
    check: go build ./... && go test ./...
    check_timeout: 15m

Succeeds when the process exits 0, and the event stream produced a terminal result rather than an error, and any declared check exits 0.

Agent steps do not take run, shell, env or instructions — those belong to other types, and setting one is a validation error rather than a silently ignored key.

4.3 command

Runs a shell command in the worktree. Requires run.

  - id: commit
    type: command
    run: 'git add -A && git commit -m "{{.Task.Title}}"'
    shell: sh                    # optional pin: sh | pwsh | cmd
    env:
      CI: "true"
    check: git log -1 --format=%s

Succeeds when the command exits 0 and any declared check exits 0.

The rendered run string is handed to one shell as a single script, so shell syntax — &&, pipes, redirection — works, subject to which shell you get (§10.1). shell: accepts exactly sh, pwsh or cmd; bash is not a value.

Command steps do not take prompt, agent, model, effort, permission_mode, on_input or instructions.

Quote a run: that contains a colon. run: git commit -m "fix: thing" is a YAML parse error, not a command — the parser sees fix: as a second mapping key. Wrap the whole value in single quotes, or use a block scalar:

    run: 'git commit -m "fix: thing"'
    run: >-
      git commit -m "fix: thing"

4.4 manual

Stops and waits for a person. Requires instructions.

  - id: review
    type: manual
    instructions: |
      Read the diff for #{{.Task.ID}} before it ships.
      Reject if any test was weakened rather than fixed.

The task enters awaiting_gate and releases its concurrency slot — the actor goroutine ends, and the task is re-admitted when a human acts. Approving advances to the next step; rejecting blocks the task with reason rejected.

instructions is a template, so a gate can tell the reviewer what they are looking at. A manual step takes no check, no agent fields, and no allow_failure — there is no failure of its own to allow. It may carry an if: guard, which is how a gate becomes conditional.

4.5 parallel

Runs its sub-steps at the same time, in the task’s one worktree. Requires steps.

  - id: verify
    type: parallel
    max_parallel: 4              # default: parallel.max_parallel (4)
    timeout: 30m                 # bounds the whole group
    steps:
      - { id: test,      type: command, run: go test ./... }
      - { id: lint,      type: command, run: golangci-lint run }
      - { id: typecheck, type: command, run: go vet ./... }

Verification is what this is for. Tests, a linter and a type check do not interact — they read the tree and report an exit code. In sequence they cost the sum of three waits; in a group they cost the longest.

The group is one step: one index, one concurrency slot, one entry in the timeline, one thing to retry.

  • It succeeds when every sub-step succeeds.
  • A failure does not cancel the others. The group waits for everything it started, then blocks with the first failure in declaration order — so you get all three verdicts, not just the first bad one.
  • A retry re-runs only what failed, including after a human retry. A passing test suite is not re-run because the linter was unhappy.

Sub-steps are ordinary agent and command steps with their own check, timeout, max_retries, retry_backoff, if: and agent selection, resolved exactly as at the top level. They may not be any other type — see §4.11.

Sub-steps share one working tree. Two of them writing the same file is a bug in your workflow. vincent isolates worktrees between tasks, not processes inside one task.

max_parallel is not covered by your concurrency caps. Those count tasks (§11); the whole group lives inside one. A board showing “1 running” can be a machine running four compilers. Size it for the hardware.

4.6 fan_out

Turns each lane into a real child task — its own worktree, branch, slot, gates, retries and transcripts — then merges those branches back into this task’s own. Requires lanes.

  - id: build
    type: fan_out
    merge:
      on_conflict: block         # block (default) | agent
    lanes:
      - id: api
        workflow: implement-module      # a registry workflow…
        fields: { module: api }
      - id: docs
        steps:                          # …or inline steps
          - { id: write, type: agent, prompt: "Document the API." }

Use it when one deliverable has parts with no reason to wait for each other — two modules that do not touch, or code in one place and docs in another. A single task cannot do that: it has one worktree, one branch and one cursor.

A lane carries id plus exactly one of workflow or steps, and optionally:

Key Meaning
if Guard. False means this lane is not spawned; siblings still run and the join still happens
fields Merged over the parent task’s fields, the lane winning
agent / model / effort Override the inherited selection for this lane’s whole subtree
priority Same, for scheduler priority

What a lane inherits. Its base branch is the parent’s branch, which is how the work lands where it belongs. Priority and the agent overrides propagate too: a fan-out inside an urgent task would otherwise queue behind unrelated work and make the urgent task slower than not fanning out.

Nesting. A lane’s workflow may itself contain a fan_out, to any depth. The bounds are fan_out.max_depth (3) and fan_out.max_tasks (64), both checked when the task is created — a cycle or an oversized tree is a 400 naming what is wrong, not something you discover as two hundred worktrees. Guarded lanes count toward those limits, because a guard is evaluated at run time and the limits are enforced before that.

How the step runs. Spawning parks the parent in awaiting_children and releases its slot; the scheduler brings it back once every descendant has settled, and that second admission runs the join. Lanes are merged --no-ff, one at a time, in declared order, stopping at the first conflict. You still get one branch to review.

Watch the children with vincent task ls --include-children, or press L on the parent in the TUI. They are hidden from the board by default; the parent’s awaiting_children (2 blocked) summary is how you learn one of them needs you.

When every lane is guarded off, the step chooses nothing and simply advances. A fan-out whose conditions all said “not this time” decided correctly.

merge and conflicts

Key Values Notes
on_conflict block (default) | agent  
agent an agent step Required by, and only valid with, on_conflict: agent

block stops the task with merge_conflict and leaves the worktree conflicted, so you resolve it in place, stage the files, and retry — the join commits your resolution and merges what is left. That is the default deliberately: an agent silently resolving a semantic conflict, with the merge commit landing unread, is the one outcome that turns a time-saving feature into a correctness liability.

agent tries an agent resolution first. It is an ordinary agent step — same fields, same validation, same executor — run in the parent’s worktree, and the conflicted files are in its context as {{.Conflicts}}. If it fails, or its check fails, or conflict markers survive it, you get the same block.

    merge:
      on_conflict: agent
      agent:
        id: resolve
        prompt: |
          Resolve the merge conflict in:
          {{ range .Conflicts }}- {{ . }}
          {{ end }}
          Keep both sides' intent. Do not delete a test to make it merge.
        check: go build ./... && go test ./...

A merge resolver may not declare on_input: require: it runs mid-join, and the worktree state it is resolving is not something a human can inspect through a question.

If a lane is cancelled or ends without finishing, the join blocks with lane_failed and merges nothing. Fix the lane — it is an ordinary task, so retry it — then retry the parent. There is deliberately no “merge the rest anyway”: a partial merge looks exactly like a complete one to everything downstream.

A fan-out fills your caps, it does not exceed them. Every lane is a task competing for max_parallel_tasks. What it buys is parallelism you would otherwise start by hand.

N lanes leave N worktrees on disk until someone archives the tree. vincent gc and vincent doctor exist for this, and you will meet it early if you fan out routinely.

4.7 condition

A step whose entire body is a guard. True continues; false ends the run and the task is done. Requires if:, and takes nothing else.

  - id: nothing-to-do
    type: condition
    if: '{{ ne (index .Steps "probe").ExitCode 0 }}'

It takes no run, no timeout, no max_retries and no allow_failure, because it starts no process: there is nothing to time out, retry, or allow to fail.

The steps after it are never considered and record nothing. The one row it leaves is in state stopped, which is where the detail view shows the run ended.

There is deliberately no “stop and block for a human” variant, because you already have one: a command step that exits nonzero. What condition adds is stop and succeed.

A condition in last position is a warning, not an error — the task is done whether it continues or stops, so the step cannot do anything a missing step would not.

4.8 loop

Runs its body — a sequence — repeatedly, in the task’s one worktree. Requires steps and exactly one of count: / for_each:.

Where a parallel group is a set run once, a loop is a sequence run more than once. It produces no branch, no child task and nothing to merge; that is fan_out.

  - id: green
    type: loop
    count: 5                     # or: for_each: […]
    max_iterations: 10           # optional ceiling; default loop.max_iterations
    steps:
      - { id: suite,  type: command, run: go test ./..., allow_failure: true, max_retries: 0 }
      - { id: passed, type: break,   if: '{{ eq (index .Steps "suite").ExitCode 0 }}' }
      - id: repair
        type: agent
        prompt: |
          The suite is red:

          {{ (index .Steps "suite").Result }}

          Fix the underlying cause. Do not weaken, skip or delete a test.

Loops have enough surface to deserve their own section: §6.4.

4.9 break

Ends the enclosing loop, successfully. Requires if:, takes id and name, and nothing else.

      - { id: passed, type: break, if: '{{ eq (index .Steps "suite").ExitCode 0 }}' }

Like condition, it starts no process, so it cannot time out, be retried, or write a transcript. It is valid only inside a loop body — elsewhere there is no loop for it to end, and that is a validation error rather than a no-op.

4.10 include

Runs another workflow’s steps here, in this task and this worktree. This is how you stop copy-pasting the same three verification steps into every workflow you own.

# .vincent/workflows/go-checks.yaml — the fragment
name: go-checks
defaults: { max_retries: 0 }
steps:
  - { id: lint, type: command, run: go run mage.go lint }
  - { id: test, type: command, run: go run mage.go test }
# .vincent/workflows/feature.yaml — a workflow that uses it
name: feature
steps:
  - { id: implement, type: agent, prompt: "{{ .Task.Description }}" }
  - { id: checks, type: include, workflow: go-checks }
  - { id: review, type: agent, prompt: "lint said: {{ .Steps.lint.Result }}" }

Create a task on feature and it runs four steps: implement, lint, test, review. The include is not one of them. It is replaced by the steps it names when the task is created, and after that nothing can tell the difference — which is why review reads .Steps.lint as if you had typed it there yourself.

Three things follow from that, and they are the whole of what you need to know:

The include takes no other fields. No if:, no timeout, no max_retries — there is no step at run time for them to apply to. To make one conditional, guard the fragment’s own steps, or put a condition in front of it.

Step ids must not collide. The expansion is one namespace, so go-checks cannot bring a lint if feature already has one, and you cannot include the same fragment twice. Name a fragment’s steps as if they were going to sit beside someone else’s, because they are.

The fragment keeps its own defaults:. go-checks says max_retries: 0, and its steps still get 0 after being spliced into a workflow that says 3. A fragment behaves the way it was written to behave. Your task-level --agent still wins over both.

Everything else is an error when you create the task, with a message naming what is wrong: a workflow that isn’t there, a cycle (a includes b includes a), more than include.max_depth levels (5 by default), a colliding step id, or a fragment whose platforms: rules out this machine. Nothing fails halfway through a run because of an include.

Careful with condition in a fragment. There is no include boundary at run time, so a condition that stops the fragment stops the whole task — the steps after the include never run. And a fragment that needs a break cannot be factored out at all, since break only validates inside a loop body.

Editing go-checks.yaml never affects a task that is already running: the task took its copy when it was created. New tasks pick up the new version.

4.11 Where each type may appear

Type Top level In a parallel group In a loop body In a lane’s inline steps
agent
command
manual
parallel
fan_out
condition
loop
break
include

include is allowed everywhere because it is gone by the time anything runs. What gets checked against this table is what it expands to — so a fragment containing a loop, included into a loop body, is refused when you create the task, with the message a hand-written nested loop would get.

A lane’s inline steps are a workflow body in their own right — they become a child task’s flat snapshot — so the “top level” column is the one that applies to them.

The ❌s all come from one fact: a parallel group and a loop iteration run inside a single admission of a single task, and each rejected type needs a task state that says “one member of this structure is parked”. There is no such state, and inventing one would mean a task that is half-waiting.

  • manual — a gate ends the actor goroutine and releases the slot.
  • fan_out — the task parks in awaiting_children; a parked parent has no row saying which iteration or which member it parked in.
  • parallel and loop inside each other — both derive their position from rows sharing one step_index, and that derivation stays affordable exactly one level deep.
  • condition inside a parallel group — a group is a set, so “end the sequence” has nothing to name. Subsetting a group is what if: on a sub-step already does.
  • on_input: require inside either — awaiting_input holds one pending request for the whole task, so a structure whose members each want to ask has nowhere to put the answers. This is judged on the resolved value, so a defaults.on_input: require reaching a silent sub-step is caught too.

Making steps talk to each other

5. Templates and data flow

5.1 Which fields are templates

Five fields are Go text/template:

Field On
prompt agent steps
run command steps
check agent and command steps
instructions manual steps
if any step, plus fan-out lanes

for_each: entries on a loop are templates too.

Rendering uses missingkey=error, and rendering happens before any process starts. A typo fails the step with template_error rather than rendering a silent hole into a prompt, and nothing has run when it does.

Templates are parsed at load time as well, so a syntax error is a validation finding on the file rather than a surprise at run time. What load cannot check is whether .Steps.foo exists — that is a run-time fact.

5.2 The template context

Variable Fields
.Task ID, Title, Description, Fields (map), BaseBranch, BranchName
.Project Name, Path (the original repo root, not the worktree), DefaultBranch
.Workflow Name, Description
.Step ID, Name, Index, Attempt (1-based)
.Steps completed steps by id → {Status, Result, ExitCode}§5.3
.Loop Index (1-based, 0 outside any loop), Item, IsFirst, IsLast§6.4
.Issue the GitHub issue the task was created from: Number (0 when there is none), Repo, Title, Body, URL, State, Labels (a list), Author, Assignee, Milestone, MilestoneNumberreference
.Host OS, Arch — the daemon’s GOOS/GOARCH — §10.4
.Worktree Path
.LastFailure retry attempts only: {Reason, Output}; empty otherwise
.Conflicts the conflicted files, on a merge.agent resolver only; empty everywhere else

Two are worth calling out because they are easy to reach for wrongly:

  • .Project.Path is the original repository, not where the step is running. The step’s working directory is .Worktree.Path.
  • There is no .Now. A guard reading wall-clock time makes a run non-reproducible, which is the property declared lane order exists to preserve.

5.3 Passing one step’s output to the next

.Steps is what makes a multi-step workflow more than a list. Each entry is:

Field Value
Status succeeded, approved, skipped, or failed
Result the agent’s final result text (agent steps), or the last 200 lines of stdout (command steps)
ExitCode the process exit code, where there was one
  - id: survey
    type: agent
    prompt: List every place this API is called. One per line, no commentary.

  - id: fix
    type: agent
    prompt: |
      A previous step reported:

      {{.Steps.survey.Result}}

      Update exactly those call sites.

Splitting work this way beats one prompt asking for both halves: each half is individually retryable, and the first half’s output is a thing the second half has to satisfy.

What appears in .Steps, and when:

  • A step that succeeded, was approved at a gate, or was skipped by its guard appears as soon as the run moves past it.
  • A step that failed appears only once the engine has advanced past it — which happens only under allow_failure (§6.2). That is the whole point of that field.
  • A step’s own failed attempt is never in .Steps["itself"] mid-retry. .LastFailure is already that channel, and two spellings of one fact is how a context rots.
  • Members of a parallel group are invisible to each other, by the same rule: nothing is visible until the engine has advanced past it, and concurrent siblings never have.
  • Inside a loop body, earlier steps of the current iteration are visible to later ones. Under repetition an id resolves to its latest iteration.
  • interrupted never appears — it is not an outcome.

Result is a tail, not a stream. 200 lines of stdout is generous for a summary and useless as a data channel. A step meant to feed for_each: should filter at the source rather than lean on the tail.

5.4 Task fields

.Task.Fields is an open map[string]string supplied when the task is created. It is how one workflow serves many jobs — a ticket id, a module name, a “ship it: yes/no” flag. A workflow can predeclare the fields it expects so the TUI can render the right rows before a task exists:

fields:
  - name: ticket
    label: Ticket
    description: Issue tracker key.
    required: true
    pattern: '^OPS-[0-9]+$'
  - name: retries
    type: integer
  - name: confidence
    type: number
  - name: dry-run
    label: Dry run
    type: boolean

The definition order is the form order. string is the default type; integer accepts base-10 whole numbers, number accepts finite decimal values, and boolean accepts exactly true or false. pattern is a Go RE2 expression for strings only — anchor it with ^ and $ when the whole value must match. label and description only improve presentation.

The daemon checks required/type/pattern rules at task creation, including for CLI and API callers. The selected root workflow owns the contract: fields from included workflows and named fan-out lane workflows are not automatically merged, so a composing workflow re-declares any input it exposes.

Declarations do not close the map. Extra fields remain accepted, recorded, inherited by lanes, and visible to templates. Both of these travel together:

vincent task add --project 1 --workflow feature-pr --title "Ship it" \
  --field ticket=OPS-42 --field owner=ana

Here ticket may be declared and validated while owner is additional metadata; both are stored as strings in .Task.Fields.

Because rendering is strict, an optional field must be read defensively or the step fails:

    prompt: |
      Implement {{.Task.Title}}.
      {{ with index .Task.Fields "ticket" }}Related ticket: {{ . }}{{ end }}

{{ index .Task.Fields "ticket" }} on its own is correct only for a field the workflow requires, where failing loudly is what you want.

5.5 Environment variables

Agent steps, command steps and checks run with the working directory set to the worktree and receive these on top of the daemon’s own environment:

Variable  
VINCENT_TASK_ID  
VINCENT_TASK_TITLE  
VINCENT_PROJECT_NAME  
VINCENT_PROJECT_PATH the original repo root
VINCENT_WORKTREE the working directory
VINCENT_BRANCH  
VINCENT_BASE_BRANCH  
VINCENT_STEP_ID  
VINCENT_STEP_ATTEMPT 1-based
VINCENT_WORKFLOW  

These are the portable way to reach task context from a script you would rather keep out of the YAML:

  - id: notify
    type: command
    run: ./scripts/notify.sh
    env:
      SLACK_CHANNEL: "#builds"

A command step’s env: is layered on top and wins. What the daemon passes down in the first place is itself configurable — see environment — but neither that policy nor a step’s env: can remove a VINCENT_* variable: those are facts about the run, not inherited state.

An agent step gets the same block, without the env: layer — env: is a command-step field. That is what lets an agent call vincent status from its own shell tool: the command reads VINCENT_TASK_ID and VINCENT_STEP_ID and needs nothing else.

5.6 Reporting status from a step

A running step can say what it is doing, in a sentence, by running vincent status from inside itself:

  - id: suite
    type: command
    run: |
      vincent status "running the store suite"
      go test ./internal/store/...

The message shows up live on the board’s STATUS column and on the attempt line in the TUI, and the last value set before the step ends stays on the finished attempt. It is the answer to “what is this actually doing” for a step that has been running for twenty-five minutes, and to “why did that fail” in terms a failure_reason like check_failed cannot reach.

For an agent step, ask for it in the prompt. The daemon deliberately does not append any instruction of its own, so an agent reports its status only because you asked:

  - id: implement
    type: agent
    prompt: |
      Implement {{.Task.Title}}.

      Before each significant phase of work, run:
        vincent status "<one short line about what you are doing now>"
      Keep it under ten words. If you get stuck or something fails, set it to
      what is actually wrong — "3 tests red in internal/store", not "working".

Worth knowing when you write that instruction:

  • Only agent and command steps can report. manual, parallel, fan_out, condition, loop and break run no process, so they have no voice and their status stays empty.
  • The message is flattened to one line, stripped of control characters and truncated to 256 bytes. Being wordy never fails the step.
  • Two messages inside one second coalesce to the later one, so an agent that narrates in a tight loop costs nothing. The first message after a quiet period is always immediate.
  • It is not a failure reason and nothing renders it as one. It is also not visible to .Steps or to an if: guard — free text an agent chose at run time is not something a workflow should branch on.

6. Control flow

A workflow can decide at run time what to do next. Three fields and two step types do it, and they compose.

6.1 if: — skip a step and carry on

Any step may carry a guard, and so may a fan-out lane. It renders like every other template here and must produce, after trimming, exactly true or false.

  - id: changelog
    type: agent
    if: '{{ eq (index .Task.Fields "changelog") "yes" }}'
    prompt: Update CHANGELOG.md.

A false guard skips that step and the workflow continues. The step still appears in the task’s step list, in state skipped with skip reason condition — so you can tell it apart from a step you skipped by hand — and downstream templates can see it in .Steps.

On a set rather than a sequence, if: means do not start this one: on a fan-out lane and on a parallel sub-step, the others still run and the join still happens. A set has no “later” to skip to, so a false guard subsets it.

Nothing renders but true or false. yes, 1, an empty string and <no value> are all condition_error, not a guess. That strictness is the point: the two things a broken guard usually produces are an empty string and a truthy word, and both mean the guard is reading something that is not there — the one case where guessing a verdict is worse than refusing to have one. condition_error is also the one failure that is not retried: re-rendering a guard cannot answer differently. Fix the workflow and retry.

Guards are re-evaluated every single time — on each attempt, on a retry, and after a daemon restart. Nothing is cached. Fix a workflow and retry a blocked step whose guard is now false, and the step is skipped. That is the point.

6.2 allow_failure: — a failure that is data

Without this, a guard can only read what a human typed when the task was created. A command step that exits nonzero blocks the task, so no step that survived long enough to be read ever has a nonzero exit code to branch on.

allow_failure: true changes that for one step: once its retry budget is spent, the workflow advances instead of blocking. The row keeps its failed state and its reason — the failure happened, it just did not stop the run — and that row is what the next guard reads.

  - id: probe
    type: command
    run: git diff --quiet HEAD~1
    allow_failure: true
    max_retries: 0               # a probe has nothing to gain from retrying
  - id: stop-if-clean
    type: condition
    if: '{{ ne (index .Steps "probe").ExitCode 0 }}'

It only swallows failures the step itself produced: a nonzero exit, a failed check, an agent error, a timeout, a transcript-cap kill. It never swallows vincent failing to run the step — a missing CLI, an expired login, a template error, an unavailable shell — nor vincent failing to record it (transcript_io_error, agent_protocol_error). Branching on “the agent is not installed”, or on “the disk filled up”, as though either were a test result is not something a workflow should be able to do.

It is valid on agent and command steps only, sub-steps of a group included, and it is not available in defaults:.

6.3 condition — finish early and succeed

Where if: skips one step, a condition step ends the run: false means the sequence stops here and the task is done.

The pairing that makes both useful is probe, then decide, in two steps:

  - id: probe
    type: command
    run: git diff --quiet {{.Task.BaseBranch}}...HEAD
    allow_failure: true
    max_retries: 0

  - id: only-if-changed
    type: condition
    if: '{{ ne (index .Steps "probe").ExitCode 0 }}'

  - id: publish
    type: command
    run: git push -u origin {{.Task.BranchName}}

Trying to do both in one step means a step whose failure is sometimes a failure and sometimes an answer, which nothing downstream can tell apart.

6.4 Loops in depth

Three shapes need a loop, and max_retries was never any of them: a retry re-runs the same step on its own failure, with no way to say “run the probe, and if it is red run a different step, then probe again”.

6.4.1 Converge — fix until green

The archetype the feature exists for:

  - id: green
    type: loop
    count: 5
    steps:
      - { id: suite,  type: command, run: go test ./..., allow_failure: true, max_retries: 0 }
      - { id: passed, type: break,   if: '{{ eq (index .Steps "suite").ExitCode 0 }}' }
      - id: repair
        type: agent
        prompt: |
          The suite is red:

          {{ (index .Steps "suite").Result }}

          Fix the underlying cause. Do not weaken, skip or delete a test.

Read it in three lines: the probe is allow_failure, so its red exit is data rather than a block; the break reads that data and ends the loop the first time it is green; the repair only runs when the break did not fire. The count: is the budget — five repairs and no more.

6.4.2 Repeat — the same thing, N times

Prove a flake is gone without ten copy-pasted steps and ten step ids:

  - id: soak
    type: loop
    count: 10
    steps:
      - { id: race, type: command, run: go test -race ./internal/taskrun }

6.4.3 Iterate — once per item

Over a list a step discovered at run time:

  - id: changed
    type: command
    run: git diff --name-only {{.Task.BaseBranch}}...HEAD -- '*.go' | grep -v _test.go
    allow_failure: true

  - id: review-each
    type: loop
    for_each: '{{ .Steps.changed.Result }}'
    max_iterations: 25
    steps:
      - { id: read, type: agent, prompt: 'Review {{ .Loop.Item }} against CLAUDE.md.' }

fan_out looks like it should fit here and does not: its lane list has to be static in the snapshot, which is exactly what lets it check for cycles and cap the tree at creation.

6.4.4 The two drivers

Driver Shape Iterations
count: an integer exactly that many, unless a break fires
for_each: a template string, or a list of them one per item

for_each: accepts both spellings and treats them identically: each entry is rendered, trimmed, and split on newlines with empty lines dropped. So [api, web] and a command’s multi-line output are one mechanism, not two. The scalar spelling exists because the motivating case is for_each: '{{ .Steps.changed.Result }}', and wrapping that in a one-element sequence would be ceremony.

Exactly one driver per loop. Both, or neither, is a validation error.

6.4.5 .Loop

Field  
.Loop.Index the 1-based iteration, and 0 outside any loop, so a shared template can tell
.Loop.Item the for_each item this pass runs on; empty for a count: loop
.Loop.IsFirst / .Loop.IsLast booleans

Item is a string, deliberately. Every other value in the context is a string, and a structured item would push a new type through the render context, the API and the TUI for a case nobody has yet.

6.4.6 There is no while:

A guard can read only what a run has already produced. On the first iteration the body has not run, so a while: reading its own body either errors out loudly or — worse — quietly renders false and the loop never runs; and one reading a step before the loop reads a constant and spins to the ceiling.

count: plus break is the same loop written correctly: post-test by construction, with the condition in the body where it can see the body, and bounded by a ceiling while: would have needed anyway.

6.4.7 continue is spelled condition

A condition step inside a loop body ends that iteration, not the run — a loop body is a sequence, and “end the sequence” ends the pass. The same word means the same thing everywhere; what changes is what it is attached to.

6.4.8 How a loop ends

Cause Result
The driver runs out, or a break fires The loop succeeds and the run advances
A condition in the body is false That iteration ends; the loop carries on
The loop cannot run within the ceiling The task blocks with loop_limit
A body step exhausts its retries The task blocks with that step’s own reason

The ceiling is the step’s own max_iterations: when it declares one, else loop.max_iterations from config (default 10). A count: above it is a validation error at load — the last moment it can be reported to the person typing it. A for_each: list longer than it blocks with loop_limit before the first iteration, naming the count, rather than quietly doing the first ten: running out of tries is not a decision the workflow made, and advancing would tell every later guard the work is finished.

Retries and iterations are different things. max_retries is for a step that failed; an iteration is for a body that succeeded and must run again. Each body step spends its own budget within each iteration. A loop itself takes no max_retries and no allow_failure — it has no attempt of its own, and “allow” on a loop could only mean “a loop_limit block advances anyway”, which is the silent success the design refused.

6.4.9 Human actions on a loop

Action Effect
skip Skips the whole loop and advances past it. There is no “skip this iteration”
retry Resumes at the failed body step of the iteration it stopped in, with a fresh budget. It does not restart at iteration 1
edit + retry Rewrites that body step in the task’s snapshot, so the fix applies to every remaining iteration

The same is true after a crash: a loop’s position is derived from its rows on every admission, so a restarted daemon resumes mid-iteration rather than redoing an hour of work.

A loop is one step: one index, one slot, one worktree, one timeline entry, and its iterations are strictly sequential. It adds no concurrency your caps cannot see. What it adds is spend — ten iterations of a three-step body is thirty agent runs, which is why the default ceiling is 10.

6.5 Guard recipes

What you want to ask Write
Did step x succeed? {{ eq (index .Steps "x").Status "succeeded" }}
Was step x skipped by its guard? {{ eq (index .Steps "x").Status "skipped" }}
Did probe x fail (under allow_failure)? {{ ne (index .Steps "x").ExitCode 0 }}
Is this not Windows? {{ ne .Host.OS "windows" }}
Did the creator ask for it? {{ eq (index .Task.Fields "ship") "yes" }}
Is this the first pass of a loop? {{ .Loop.IsFirst }}
Is this step inside a loop at all? {{ gt .Loop.Index 0 }}
Is a field present at all? {{ if index .Task.Fields "ticket" }}true{{ else }}false{{ end }}

.Steps.x.Status is valid shorthand for (index .Steps "x").Status when the id is a bare identifier; the index form is required for ids containing - or ..


Making it reliable

7. Verification with check

7.1 Why every writing step wants one

check is a field on agent and command steps, not a step type. It is the difference between “the agent said it was done” and “the compiler agrees”.

  - id: implement
    type: agent
    prompt: 
    check: go build ./... && go test ./...
    check_timeout: 15m

If a step produces something checkable, check it. If a step genuinely produces nothing checkable, that is usually a sign it is doing two things.

7.2 How a check runs

  • After the step body, in the worktree, with the same environment as a command step (§5.5) and under the same shell rules (§10.1).
  • Its output is captured to the step’s transcript.
  • Non-zero fails the attempt with reason check_failed, and the retry gets the failure appended to its prompt automatically:
<previous-attempt-failure attempt="1">
reason: check command failed (exit 1)
--- output (last 200 lines) ---
…
</previous-attempt-failure>
  • check_timeout: bounds it, defaulting to the daemon’s defaults.command_timeout (15m) — not to the step’s own timeout and not to workflow defaults.timeout. A long agent step with a short check is the usual shape, so set check_timeout: explicitly when the check is the slow half.

7.3 Inverting a check

When the deliverable is a failure, invert it:

  - id: reproduce
    type: agent
    prompt: Write a test that fails, reproducing the bug described above.
    check: '! go test ./...'

Without this, an agent asked to “add a failing test and fix it” reliably writes a test that passes against its own fix. See fix-and-test.yaml. Note that ! is POSIX shell syntax — a file relying on it should declare platforms: [posix] or pin shell: sh.

7.4 What a check is not

  • It is not a second step: it has no id, no row of its own, and no retry budget separate from its step’s.
  • It cannot be set in defaults:.
  • It is not available on manual, parallel, fan_out, condition, loop or break steps. To verify after a group, put a command step behind it.

8. Failure, retries and timeouts

8.1 What counts as success

Type Succeeds when
agent the process exits 0, and the stream produced a terminal result rather than an error, and any check exits 0
command the command exits 0, and any check exits 0
manual a person approves
parallel every sub-step succeeds
fan_out every lane finishes and every branch merges
loop the driver runs out or a break fires
condition its guard is true (false ends the run, also successfully)

8.2 Retries

defaults:
  max_retries: 2      # attempts AFTER the first — so up to three

The default is 1, i.e. up to two attempts. 0 means one attempt and no retry, which is right for probes and for anything with a side effect you would not want repeated — a push, a release, a comment on an issue.

Set it per step where the step’s cost says so:

  - id: publish
    type: command
    run: git push -u origin {{.Task.BranchName}}
    max_retries: 0

Retries are for failures a retry can plausibly fix. condition_error is excluded by construction, and an interrupted attempt does not consume budget. parallel groups and loop steps carry no retry budget of their own — their members do.

retry_backoff: paces them. By default a retry is immediate, which is right for a compile error the agent can see and fix, and wrong for anything transient — a flaky network call, a git index.lock held by another process — where two guaranteed failures inside a few seconds spend the budget on nothing:

  - id: smoke
    type: command
    run: ./scripts/smoke.sh
    max_retries: 2
    retry_backoff: 30s   # wait before each retry; 0s (the default) is immediate

A non-zero value does not sleep. The task goes back to queued with the resume time attached, gives up its concurrency slot so other work keeps running, and re-runs the step by itself when the wait is over — the same mechanism a usage limit uses, and it needs nothing from you. The board shows queued → 14:20 and the detail header queued · retry backoff → 14:20.

The wait decides when an attempt happens, never whether there is one. The attempt still counts against max_retries, and a step out of budget blocks at once however long its backoff. So a task that keeps reappearing on a backoff is on its way to blocking; see Troubleshooting for telling it apart from a quota wall.

It is settable in defaults: and per step, where the step wins — including retry_backoff: 0 on one step to opt it out of a workflow-wide default. It is refused on condition, break, loop and include steps, exactly as max_retries is, and the on_conflict: agent merge resolver never waits: its attempts belong to the join.

8.3 Timeouts

timeout: bounds one attempt, and can be set in defaults: or per step. Without either it is the daemon’s defaults.agent_timeout (60m) for agent steps and defaults.command_timeout (15m) for everything else.

On a parallel group or a loop, timeout: bounds the whole structure rather than one member.

Two related bounds that are not the step timeout:

  • check_timeout:§7.2.
  • input_timeout: — bounds each wait in awaiting_input, per request. The step’s own timeout clock pauses while a question is pending: it measures agent work, not human latency.

8.4 When a step runs out of attempts

The task blocks, carrying that step’s failure reason, and waits for a human. Nothing is silently abandoned and nothing downstream runs. From there you can retry, skip, edit + retry, or cancel.

The one exception is allow_failure:, which turns the step’s own failures into an advance.

The full reason vocabulary — check_failed, nonzero_exit, agent_error, timeout, loop_limit, merge_conflict, lane_failed, and the rest — is in Task lifecycle → failure reasons. A reason means the same thing wherever it originated.

8.5 Interruption is not failure

vincent is crash-first: every transition is persisted before it is acted on. A daemon that stops mid-step marks the run interrupted, kills verified orphan processes, and re-runs the step as a fresh attempt that does not consume a retry.

This is safe because every agent step is a fresh session over a worktree whose committed state survives. You help by having agents commit incrementally — say so in the prompt. A step that does an hour of work and commits at the end loses the hour; one that commits as it goes loses minutes.


9. Agents, models and permissions

9.1 Resolution order

For each agent step, agent, model and effort resolve first-hit-wins:

  1. the explicit step field;
  2. the task-level override chosen at creation (--agent, --model, --effort) — this replaces workflow defaults, never an explicit step field;
  3. workflow defaults;
  4. the adapter default — usually empty, meaning the CLI decides.

Agent-scoped inheritance. model and effort only inherit from a level whose resolved agent matches the step’s. A step that switches agent without setting them gets the new adapter’s defaults rather than a claude alias leaking onto a codex step.

defaults:
  agent: claude
  model: sonnet

steps:
  - id: implement
    type: agent          # → claude / sonnet
    prompt: 
  - id: review
    type: agent
    agent: codex         # → codex / codex's default model, NOT sonnet
    effort: high
    prompt: 

POST /v1/resolve answers “what does this resolve to, and which level won” for every step in a workflow. That is what the TUI’s new-task form renders; no client re-derives it.

9.2 What each adapter can do

Adapters differ, and the differences are documented rather than smoothed over. A capability an adapter lacks is ignored at run time; it is never emulated.

  claude codex cursor
Mid-run questions (on_input)
Reports cost
effort: (it lives in the model id)
Model catalog — (account-dependent; free text) ✅ (probed over the network)
restricted on Windows (the step fails; it never downgrades)

vincent workflow validate catches a model or effort value belonging to another adapter’s catalog — claude’s sonnet or max reaching a codex step is an error. A value in no catalog is a warning and passes: the CLI is the final authority at run time, so free-text models and future CLI values are not blocked. What validation cannot catch is a model your account lacks.

Full details per adapter: Agent CLIs.

9.3 Permission modes

permission_mode: full-auto     # default

full-auto is the default and the agent can run arbitrary commands as you. The worktree isolates collisions between tasks, not privileges. This is a documented design decision, not an oversight — see the Security model.

restricted maps to each adapter’s own confinement. Use it for steps that have no business running commands — a docs pass, a review. Two things to know:

  • An adapter that cannot restrict on your platform fails the step with restricted_unsupported rather than running it unrestricted. A restricted mode that quietly is not restricted is worse than none.
  • It changes agent behaviour in ways that read as bugs if you did not ask for them: claude turns every non-allowlisted tool into a permission prompt. That is why the shipped examples are all full-auto.

9.4 Mid-run questions (on_input)

An input-capable adapter (claude today) can surface a structured question from the agent mid-step. Only machine-readable requests from the event stream qualify — vincent never guesses that some output text “looks like a question”.

defaults:
  on_input: wait        # wait (default) | deny | require
  input_timeout: 24h    # bounds each wait; also settable per step

wait — the task moves to awaiting_input and keeps its concurrency slot: the agent process is alive, idle on its stdin. The step’s timeout clock pauses. The board pins the task, rings the terminal bell, and the answer form opens on enter. Answering resumes the same session where it stopped.

deny — for runs that must stay strictly unattended. vincent answers immediately through the adapter: questions get a canned “no user is available; decide with your best judgment”, and permission requests are denied. The task never leaves running.

requirewait, plus a promise that a human is part of the run. Use it when the conversation is the workflow:

  - id: clarify
    type: agent
    on_input: require
    prompt: Ask me whatever you need before you start, then implement it.

wait and deny both degrade quietly on an agent with no control channel: the step runs, nothing is asked, and the agent decides alone. That is usually what you want — but not for a step built around asking you which of three designs to take. There, an agent that cannot ask does not degrade, it guesses, and nothing in the run says so. require makes that explicit, and you notice it in four places:

  • In the file. Pinning agent: codex or agent: cursor on a requiring step is a validation error — those CLIs have no control channel in any version.
  • At task creation. The agent picker greys out an agent that cannot answer questions, and POST /v1/tasks refuses one with a 400 naming the step. A workflow whose requiring steps leave their agent to the task is marked needs an interactive agent in the picker.
  • Never on a guess. An agent that is not installed, or whose probe did not answer, is unknown — and unknown never refuses anything. Only a definite “this one cannot” blocks you.
  • At run time. If the answer changes underneath you — claude upgraded past the version family vincent has verified the protocol against — the step fails with input_unsupported instead of running an unattended conversation.

require is not valid inside a parallel group, inside a loop body, or on a merge.agent resolver: awaiting_input holds one pending request for the whole task. A step’s own on_input still wins over defaults:, so a mostly-interactive workflow can mark one long cleanup step on_input: deny and leave it unattended.


10. Portability

Windows, macOS and Linux all run vincent, and vincent never translates a command step between shells. Portability of command steps is the author’s job — or an explicit declaration that you did not do it.

10.1 Which shell runs a command step

run and check are rendered, then handed to one shell as a single script:

Platform Shell
POSIX /bin/sh -c "<rendered>"
Windows pwsh -NoProfile -Command "<rendered>", falling back to Windows PowerShell (powershell)

Pin one with shell: when a step is only ever meant to run one way:

Value Runs as
sh /bin/sh -c (on Windows: whatever sh is on PATH — Git Bash’s, if you put it there)
pwsh pwsh -NoProfile -Command
cmd cmd /C

Those three are the whole vocabulary. bash is not a value — use sh for portable POSIX syntax, or invoke bash explicitly with run: bash -c '…' when you genuinely need bash-only features. A pinned shell is never silently replaced: if it is not installed, the step fails with shell_unavailable.

10.2 Writing portable command steps

If a file must run everywhere, spell its command steps in the intersection of sh and pwsh:

Works in both Does not
git …, and any binary on PATH test -f x, [ -f x ]
&& and \|\| between simple commands for / if / case
exit N as the whole command touch, seq, cat, grep

Two tricks worth knowing, both borrowed from vincent’s own acceptance gates:

  • A step that must write a file portably: git config -f <file> <key> <value>.
  • A step that must pass or fail on a condition: use one command whose own exit code says so. git config --get <key> exits 1 on a missing key.

exit N must be the whole command body. In pwsh, && takes pipelines, so … && exit 0 parses as a command named exit and fails.

If that list feels like a straitjacket, it is — which is what §10.3 is for.

10.3 platforms: — declare what you did not port

A workflow that pipes cat into wc is a POSIX workflow. Say so once, at the top of the file:

name: posix-tools
platforms: [posix]
Token Matches
linux Linux
darwin macOS
windows Windows
posix Every non-Windows host — the shorthand for “needs a POSIX shell”

Any combination is allowed: [linux, darwin] is [posix] spelled out, and [posix, windows] is the same as omitting the key. Tokens are matched exactly — macos and Linux are validation errors, not silent non-matches. Duplicates are errors too.

On a host the list does not admit:

  • the workflow is still listed — by vincent workflow ls with status unsupported, and in the TUI’s workflow view, with the platforms it needs;
  • it cannot be selected: the new-task picker refuses it and POST /v1/tasks rejects it with a 400 naming the restriction and the host;
  • a task that somehow already carries it — a data directory moved between machines — blocks at admission with platform_unsupported, before any step runs.

vincent workflow validate checks the tokens but never the host, so a POSIX-only workflow validates the same on a Windows CI runner as on Linux. That is what makes it usable as a portable pre-commit check.

The restriction is whole-workflow. Omitting the key means “runs anywhere”, which is the right answer for most files.

10.4 Gating one step by platform

There is no per-step platforms:, and none is needed: .Host plus an ordinary guard says the same thing with skip semantics that are already defined.

  - id: unix-only-cleanup
    type: command
    if: '{{ ne .Host.OS "windows" }}'
    run: rm -rf ./tmp

.Host is the daemon’s GOOS and GOARCH, because the daemon is what runs the steps. Use platforms: when the whole file should not be offered; use if: when one step should be skipped.


11. Concurrency, cost and limits

Four different limits bound a run. They are easy to confuse, and only the first counts tasks.

Limit Counts Default Set in
max_parallel_tasks concurrently running tasks 3 config.yaml, plus a per-project cap
parallel.max_parallel sub-steps of one group at once 4 config.yaml, or max_parallel: on the step
fan_out.max_depth / max_tasks depth and size of a fan-out tree 3 / 64 config.yaml, checked at task creation
loop.max_iterations iterations of one loop 10 config.yaml, or max_iterations: on the step

What follows from that:

  • A parallel group hides work from the board. One task at max_parallel: 8 starts eight processes while the board shows a single running task. Size it for the machine.
  • A fan_out fills your caps rather than exceeding them. Every lane is a task competing for max_parallel_tasks like any other.
  • A loop adds no concurrency at all — its iterations are strictly sequential. What it adds is spend: ten iterations of a three-step body is thirty agent runs.
  • Steps that release their slot: manual (awaiting_gate) and fan_out while its children run (awaiting_children). A step waiting on a mid-run question (awaiting_input) keeps its slot, because the process is alive.

Three more bounds worth knowing when a run behaves oddly: transcript_max_bytes (512MB per attempt, then transcript_limit); usage_limit, which is not a failure — the task waits queued until the agent’s quota window reopens, consuming no retry; and max_task_cost_usd, off by default, which blocks a task with cost_limit once its spend across every attempt passes a ceiling you set. That last one counts one task, so each fan-out lane carries its own budget, and it only sees agents that report cost.


Shipping

12. Patterns that work

Split what an agent does badly in one go. A survey step whose output feeds a fix step beats one prompt asking for both: .Steps carries the first result into the second, and each half is individually retryable.

Make the deliverable checkable. check: go build ./... && go test ./... on the step that writes code turns a claim into a verdict. A step with nothing checkable is usually doing two things.

Invert the check when failure is the point. check: '! go test ./...' on a step whose job is to produce a red test — see fix-and-test.

Gate before anything leaves the machine. Put a manual step in front of any step that pushes, publishes, deploys or deletes. The gate costs one keystroke and is the difference between “an agent wrote to a branch” and “an agent wrote to production”.

Guard the expensive step, not the whole workflow. Splitting one file into with-changelog and without-changelog moves the decision to task creation — the one moment the facts that decide it do not exist yet. An if: on the step keeps one workflow and lets the run decide.

Probe, then decide, in two steps. allow_failure on a cheap command that answers a question, then a condition or an if: that reads its exit code.

Converge with a loop, not with retries. max_retries re-runs the same step; converging needs probe → decide → repair, which is a loop body.

Ask agents to commit incrementally. It is what makes crash recovery cheap, and it costs one sentence in the prompt.

Set max_retries: 0 on anything with a side effect. A push, a release, a comment on an issue: retrying it does something twice.

Use restricted for steps that only read and write text. A docs pass has no business running commands; the mode says so and the adapter enforces it.

13. Reviewing a workflow before you commit it

  • vincent workflow validate <file> passes with no warnings.
  • Every step that produces something checkable has a check:.
  • Prompts say what “done” means, not just what to attempt.
  • Any step that pushes, publishes or deletes sits after a manual gate and carries max_retries: 0.
  • Optional .Task.Fields reads are wrapped in {{ with }}.
  • Command steps work in the shell your teammates have — or the file declares platforms:.
  • Loops have a break or a count: you would be happy to pay for.
  • Step ids read like nouns you would want to see in a timeline.
  • The file name matches the name: field.

Wiring it into CI is one line, and it needs no daemon:

for f in .vincent/workflows/*.yaml; do vincent workflow validate "$f" || exit 1; done

14. Troubleshooting

Symptom Cause
unknown field "…" at load A typo, or a field on the wrong step type. Unknown keys are never ignored
template does not parse A syntax error in prompt/run/check/instructions/if. Caught at load
template_error at run time A reference to something that is not there — usually an optional .Task.Fields key read without {{ with }}, or a .Steps id that has not completed
condition_error A guard rendered something other than true/false. Not retried — fix the file and retry
shell must be one of sh, pwsh, cmd shell: bash — see §10.1
count N exceeds the M-iteration ceiling Raise max_iterations: on the step, or loop.max_iterations in config
break steps are only valid inside a loop body A break at the top level or in a group
agent "codex" can never take mid-run input on_input: require resolved to an adapter with no control channel
A model name is an error, not a warning It belongs to another adapter’s catalog. A model in no catalog only warns
Workflow listed but not offered platforms: does not admit this host — §10.3
The workflow ran an old version A task uses the snapshot captured at creation. Use edit + retry, or create a new task
A group hangs long after one sub-step failed By design: the group waits for everything it started, then reports the first failure in declaration order

Anything not on this list is probably a task-level rather than a workflow-level problem: see Troubleshooting and Task lifecycle.


See also