Workflow schema
The daemon serves this table as data at GET /v1/workflows/schema — which
fields are legal on which step type, and where each type may be nested. The
TUI’s structured editor renders its forms from it rather than carrying a second
copy, which is why a form cannot offer you a field this page says is illegal.
The complete YAML field reference. For a practical, pattern-oriented tour, see Writing workflows.
Unknown keys are errors, not ignored. A typo fails validation instead of becoming a setting that silently never applied.
The file
Steps
- Step fields — common to every type
type: agenttype: commandtype: manualtype: paralleltype: fan_out·mergeand conflictstype: loop·.Loop· body contents · ending · human actionstype: breaktype: include— reuse another workflow- Nesting rules — which type may appear where
Behaviour
File structure
name: feature-pr
description: One line, shown in the picker.
fields:
- name: ticket
label: Ticket
required: true
pattern: '^OPS-[0-9]+$'
defaults:
agent: claude
max_retries: 2
timeout: 45m
steps:
- id: implement
type: agent
prompt: |
Implement {{.Task.Title}}.
check: go build ./... && go test ./...
Files live in .vincent/workflows/*.yaml (project scope), or
{config_dir}/workflows/*.yaml (global). Project shadows global by name.
Three built-in workflows are always present: adhoc, a single agent step;
create-workflow, a single agent step that writes another workflow file into
one of the two registries; and
update-workflows,
which rewrites the workflows a project already versions against everything on
this page and validates each one.
Top level
| Key | Type | Required | Notes |
|---|---|---|---|
name |
string | ✅ | How tasks refer to it. Unique per scope |
description |
string | Shown in the TUI’s workflow picker | |
platforms |
list | Where this workflow may run. Empty means anywhere — see platforms |
|
fields |
list | Ordered task-input declarations — see fields |
|
defaults |
map | Inherited by every step | |
steps |
list | ✅ | Runs in order, top to bottom. Must be non-empty |
platforms
Vincent never translates a command step between shells, so a workflow that
pipes cat into wc is a POSIX workflow. Declaring that keeps it from being
offered on a host it cannot run on:
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,
[posix, windows] is the same as omitting the key. Tokens are matched exactly
— macos and Linux are validation errors, not silent non-matches.
On a host the list does not admit:
- the workflow is still listed — by
vincent workflow ls(statusunsupported) 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/tasksrejects it with a 400; - 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 it does on
Linux.
The restriction covers the whole workflow; there is no per-step platforms.
fields
A workflow can declare the task fields it expects. The TUI pre-renders these rows as soon as the workflow is selected, while the daemon validates them for every client when the task is created.
fields:
- name: ticket
label: Ticket
description: Issue tracker key, including its project prefix.
type: string
required: true
pattern: '^OPS-[0-9]+$'
- name: retries
type: integer
- name: threshold
type: number
- name: dry-run
label: Dry run
type: boolean
- name: environment
type: enum
required: true
values: [dev, staging, prod]
default: staging
- name: reviewers
type: enum
multiple: true
values: [ana, bo, cy]
Definitions stay in source order. Each definition has:
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
name |
slug | ✅ | Key in .Task.Fields; unique in this list |
|
label |
string | name |
Presentation text only | |
description |
string | Help text shown by clients | ||
type |
string | integer | number | boolean | enum |
string |
Editing and validation contract; stored value is still a string | |
required |
bool | false |
Missing or whitespace-only values are rejected when true | |
pattern |
string | Go RE2 expression, valid only for string; use ^ and $ for a whole-value match |
||
values |
list of scalars | for enum |
The members, in declared order. Required for enum, rejected on every other type; non-empty, unique, no member may be empty or contain , |
|
multiple |
bool | false |
enum only: the field accepts more than one member |
|
default |
scalar | Any type. Applied when the caller omits the key; validated against this declaration when the workflow loads |
Integers are base-10 whole numbers, numbers must be finite decimals, and
booleans are exactly true or false. Optional absent or empty values skip
type and pattern validation.
enum fields
An enum publishes its members, which is what a regex cannot do: a client can
build a picker from a list and not from '^(dev|staging|prod)$'. New task opens
a scrollable value list on enter, and for a single-choice field steps through
the members in place with ←/→, the way a boolean cycles. A multiple field
is changed only through the list.
A multiple: true field stores the picked members joined with , in declared
order, deduplicated, with no spaces — dev,prod. POST /v1/tasks normalizes
what it is given that way before checking membership, so --field
reviewers="cy, ana" is stored as ana,cy and the same selection always
produces the same string. That is what keeps template output and branch names
stable. A member may therefore not contain ,.
pattern and enum are mutually exclusive: the members are the constraint.
default
default: is not enum-specific — any declared field may carry one — and it is
written as the value’s own YAML scalar: default: true on a boolean, default:
3 on an integer, default: staging on a string or an enum. A multiple enum
may also take a list (default: [dev, prod]), normalized like any other value.
The daemon substitutes a required field’s default when the caller omits the
key, so a scripted vincent task add that omits it succeeds and the task row
records what applied. It never substitutes an optional field’s default: that
one is published to clients, and New task seeds the row with it, but an optional
key the caller omitted stays absent from .Task.Fields. Adding a default: to
an existing optional field therefore does not change what
{{ with index .Task.Fields "x" }} sees.
The task map remains open. A caller may send additional names; vincent
records them and exposes them to templates just like declared fields. Only the
declared names receive required, type, pattern and membership checks. A client
older than enum sees an unknown type, falls back to a free-text row and runs
no local check; the daemon still rejects a non-member.
The selected workflow is the public boundary. Fields declared by an included
workflow or a named fan_out lane are not merged into the caller’s form. A
composing workflow re-declares the inputs it exposes; lane fields: remains a
separate map of values bound for that lane — and those overrides are not checked
against the root’s declarations, so a lane may bind a name the root declares as
an enum to something that is not a member.
defaults
Every key here is also settable per step, where it wins.
| Key | Type | Default | Applies to |
|---|---|---|---|
agent |
claude | codex | cursor |
daemon default | agent steps |
model |
string | adapter default | agent steps |
effort |
string | adapter default | agent steps (ignored by cursor) |
permission_mode |
full-auto | restricted |
full-auto |
agent steps; restricted also gates which agents may run the step |
on_input |
wait | deny | require |
wait |
agent steps; require also gates which agents may run the step |
input_timeout |
duration | 24h (config) |
agent steps |
max_retries |
int | 1 |
all steps |
retry_backoff |
duration | 0s |
all steps |
timeout |
duration | 60m agent / 15m command (config) |
all steps |
container |
mapping | daemon container: |
command steps and checks — where the task’s step processes run |
Durations are Go duration strings: 45m, 1h30m, 90s.
container is the one key here that is a mapping rather than a scalar, and it
merges per field over the daemon’s container:
block rather than replacing it:
defaults:
container:
image: ghcr.io/acme/dev:latest # "" here forces this workflow onto the host
network: false # inherit runtime, mount_agent_config from config.yaml
Every field is optional and an absent one keeps the daemon’s value; image: ""
is a real value meaning “run this workflow’s tasks on the host”, distinct from
saying nothing. There is no per-step and no per-task container override.
What runs inside it today is every command step and every check:,
including a check on an agent step. A manual step runs no process. The
agent process itself is still spawned on the host; moving it is the next
piece of this work, and until it lands a workflow that pins an image and has
agent steps is a mixed run.
Pinning an image here also makes one check possible at load rather than at task
creation: a step that pins shell: pwsh or shell: cmd fails validation
straight away, because a containerized run: body executes under the image’s
/bin/sh. A workflow that pins no image is only refused when a task is created
from it, since containerization can also come from the hot-reloadable
config.yaml.
max_retries counts attempts after the first, so the default of 1 means up
to two attempts.
retry_backoff is how long to wait before each of them. The default of 0s is
an immediate retry — which is right for a compile error the agent can see and
fix, and wrong for a flaky network call or a git index.lock held by another
process, where two guaranteed failures inside a few seconds spend the budget on
nothing. A non-zero value does not sleep: the task returns to queued with
a wait attached and gives up its slot, so a paced task never stops other work
from running. See States in the lifecycle reference for
what that looks like from outside. Neither field has a
config.yaml key: retry policy belongs to a workflow.
Step fields
Common to every step:
| Key | Type | Required | Notes |
|---|---|---|---|
id |
slug | ✅ | Unique within the file, sub-steps included. How .Steps addresses it |
name |
string | Display name; defaults to id |
|
type |
agent | command | manual | parallel | fan_out | condition | loop | break | include |
✅ | check is a field, not a type |
max_retries |
int | Overrides defaults |
|
retry_backoff |
duration | Wait before each retry; overrides defaults. 0s means retry at once, which is the default. Not valid on condition, break, loop or include steps, which own no attempt |
|
timeout |
duration | Per attempt; overrides defaults. On a parallel group or a loop, bounds the whole thing |
|
if |
template | Guard: skip this step unless it renders true. See Conditions |
allow_failure: true is available on agent and command steps only; it is
covered under Conditions, because it exists to give a guard
something to read.
type: agent
Runs an agent CLI headlessly in the worktree.
| Key | Type | Required | Notes |
|---|---|---|---|
prompt |
string | ✅ | Go text/template |
agent |
string | claude, codex or cursor |
|
model |
string | Adapter-native id or alias | |
effort |
string | Adapter-native. Cursor has none — it lives in the model id | |
permission_mode |
string | full-auto (default) or restricted. restricted needs an adapter that can restrict on this host — cursor cannot on Windows, and a task whose restricted step resolves there is refused at creation |
|
on_input |
string | wait (default), deny, or require. wait/deny have no effect on codex or cursor; require refuses them |
|
input_timeout |
duration | Bounds each wait in awaiting_input, per request |
|
check |
string | Shell command that must exit 0 for the attempt to succeed | |
check_timeout |
duration | Defaults to the daemon’s defaults.command_timeout (15m) — not the step’s own timeout, and not workflow defaults.timeout |
- id: implement
type: agent
agent: claude
model: sonnet
effort: high
permission_mode: full-auto
prompt: |
Implement {{.Task.Title}}.
check: go build ./... && go test ./...
check_timeout: 15m
Succeeds when the process exits 0, and the event stream produced a terminal
result (not an error), and any declared check exits 0.
Every agent step is a fresh session — no conversation is resumed between
steps or attempts. State flows through the worktree and through {{.Steps}}.
type: command
Runs a shell command in the worktree.
| Key | Type | Required | Notes |
|---|---|---|---|
run |
string | ✅ | Go text/template, then handed to a shell |
shell |
sh | pwsh | cmd |
Pin the shell; default is the platform’s | |
env |
map | Extra environment for this step | |
check |
string | Must exit 0 for the attempt to succeed | |
check_timeout |
duration | Defaults to the daemon’s defaults.command_timeout (15m) |
- id: commit
type: command
run: 'git add -A && git commit -m "{{.Task.Title}}"'
shell: sh
env:
CI: "true"
Default shells: /bin/sh -c on POSIX, pwsh -NoProfile -Command on Windows
(falling back to powershell). Vincent does not translate between them —
portability is the author’s job.
shell: accepts exactly three values — bash is not one of them. Use sh
for portable POSIX syntax, or invoke bash explicitly with run: bash -c '…'.
| Value | Runs as |
|---|---|
sh |
/bin/sh -c; on Windows, whatever sh resolves to on PATH (Git Bash’s, if it is there) |
pwsh |
pwsh -NoProfile -Command |
cmd |
cmd /C |
A pinned shell is never silently replaced: if it is not installed the step
fails with shell_unavailable.
Quote a run: containing a colon — run: git commit -m "fix: thing" is a YAML
parse error, since the parser reads fix: as a second mapping key.
type: manual
Stops and waits for a person.
| Key | Type | Required |
|---|---|---|
instructions |
string | ✅ |
- id: review
type: manual
instructions: |
Read the diff for #{{.Task.ID}} before it ships.
The task enters awaiting_gate and releases its concurrency slot. Approving
advances; rejecting moves the task to blocked.
type: parallel
Runs its sub-steps at the same time, in the task’s one worktree.
| Key | Type | Required |
|---|---|---|
steps |
list of steps | ✅ |
max_parallel |
int | — (default parallel.max_parallel, 4) |
- id: verify
type: 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 ./... }
Sub-steps are ordinary agent and command steps: each has its own check,
timeout, max_retries, retry_backoff, if: and agent selection, resolved
exactly as at the top level. A sub-step that backs off takes the whole group
with it — the group waits for its in-flight siblings, then the task waits, and
the re-admitted group re-runs only what is left. Those two types are the whole list — see
Nesting rules for what is refused and why:
manual— a gate releases the task’s slot, and there is no such thing as half a task waiting at a gate;- another
parallel, andloop— both derive their position from rows sharing onestep_index, and that derivation stays affordable one level deep; fan_out— the task parks inawaiting_children, and a group cannot park half a task;condition— a group is a set with no later steps to stop; guard the sub-step withif:instead;break— there is no loop here for one to end;on_input: require— the task has one pending question at a time, so a group of agents that each want to ask has nowhere to put the answers. This is judged on the resolved value, so adefaults.on_input: requirereaching a silent sub-step is caught too.
The group is one step: one index, one slot, one entry in the timeline. 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. A retry re-runs only what failed, including after a human retry: sub-steps that already succeeded are not run again.
max_parallelis not governed by your concurrency caps. Those count tasks (max_parallel_tasks, §11); a group runs inside one of them. One task atmax_parallel: 8will happily start eight compilers while the board shows a single running task. Size it for the machine, not for the board.
Sub-steps share a working tree. Two of them writing the same file is a bug in your workflow — vincent isolates worktrees between tasks, not processes inside one.
type: fan_out
Turns each lane into a real child task with its own worktree and branch, then merges those branches back into this task’s own.
| Key | Type | Required |
|---|---|---|
lanes |
list of lanes | one of lanes or lane |
lane |
one lane template | one of lanes or lane, with for_each |
for_each |
template or list of templates | required by lane |
max_lanes |
int | — |
schedule |
barrier (default) or eager |
— |
merge |
map | — |
- 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." }
A lane carries id and exactly one of workflow or steps, plus:
| Key | Type | Notes |
|---|---|---|
if |
template | Guard. False means this lane is not spawned; siblings still run and the join still happens |
needs |
list of lane ids (or one id) | Sibling lanes that must be done and merged before this lane spawns. Empty means “immediately”, which is every lane without it |
fields |
map | Merged over the parent task’s fields; the lane wins |
agent / model / effort |
string | Override the inherited selection for this lane’s whole subtree |
priority |
int | Same, for scheduler priority |
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.
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.
A lane list with needs is a DAG, and the step runs it in rounds. Each
admission merges every lane that is done and not yet on this task’s branch,
spawns the lanes those merges have just made eligible, and parks again. Because
a round merges before it spawns, a dependent lane’s worktree is cut from a
branch that already contains its dependencies’ commits — that is what the
dependency actually delivers. The wave structure is derived from the graph: no
workflow names a wave and none names a maximum.
lanes:
- { id: api, workflow: impl }
- { id: db, workflow: impl }
- { id: wire, workflow: impl, needs: [api, db] }
needs is happens-after, not isolation. There is one branch, so a lane
declaring needs: [api, db] also sees docs when docs merged in the same
round; dependencies are satisfied at least, never exactly. An unknown lane id or
a cycle is refused when the workflow loads.
Scheduling is by barrier round unless you say otherwise: the parent wakes
when every descendant has settled, so a lane that needs only wire still waits
for its whole round. That is what makes “what did this lane start from”
reproducible across re-runs.
schedule: eager takes the other trade. A lane is merged and its dependents
spawned as soon as its own needs are done and merged, without waiting for
unrelated siblings:
- id: build
type: fan_out
schedule: eager # barrier (default) | eager
lanes:
- { id: api, workflow: impl }
- { id: slow, workflow: impl }
- { id: wire, workflow: impl, needs: [api] }
wire starts as soon as api merges, even with slow still running. The price
is that a lane’s starting tree becomes timing-dependent: whether slow had
merged by the time wire was cut is a stopwatch question, so re-running the
task can give wire a different tree and a different result. The parent
branch’s commit topology is not reproducible either — the set of lanes a given
merge commit joins varies between runs — even when the delivered tree is.
barrier is the reproducible default for that reason; a workflow trading
reproducibility for throughput says so in the file.
Everything else is identical under both modes: lane_failed, merge_conflict,
the cancel cascade and the archive refusal. A step whose selected lanes declare
no needs among themselves runs as a barrier whatever schedule says — such a
list is one round either way, so schedule: eager on it is redundant rather
than wrong.
The lane list can be derived from an earlier step. Give the step for_each:
and a single lane: template instead of lanes::
- id: plan
type: agent
prompt: "Inspect the repo. Emit the work units and their dependencies."
- id: build
type: fan_out
max_lanes: 24
for_each: '{{ (index .Steps "plan").Result }}'
lane:
id: '{{ .Item.id }}'
needs: '{{ .Item.needs }}'
workflow: implement-module
fields: { module: '{{ .Item.id }}' }
for_each is rendered the way a loop’s is — every entry rendered, trimmed,
split on newlines, empty lines dropped — and each line is then parsed as a
JSON object. .Item in the lane: template is that object, which is the one
place a template value is not plain text: a node of a graph carries an identity
and its edges. A line that is not a JSON object blocks with
fan_out_invalid, naming the line.
Only id, needs, fields and if may vary per item — the lane’s
workflow: stays static, so the registry is still read exactly once, when the
task is created. Derivation runs once, at spawn, and the derived lanes are
written into the task’s own snapshot; after that the step is an ordinary
fan_out everywhere you look at it. An empty derived list is a no-op success.
Because a derived width cannot be counted at task creation, max_lanes: and a
run-time check against fan_out.max_tasks block with fan_out_limit before
anything is spawned. fan_out.max_depth is unaffected: it counts nesting, and a
dynamic width does not nest.
One branch is still delivered. The step does not finish until every lane is
merged, --no-ff, in the order the lanes are declared. Order is the lane’s
declared index, not its position among the lanes actually spawned, so a
guarded-off lane does not renumber the merge.
Spawning parks the parent in awaiting_children and releases its slot; the
scheduler re-admits it once every descendant has settled — or, under
schedule: eager, once a further lane has — and that admission runs the join. If the spawn is only partial, the lanes already created
are cancelled, so a retry starts from a clean slate rather than half a tree.
When every lane is guarded off, the step chooses nothing and advances. It
must not park: a parent in awaiting_children with no children would be
re-queued forever.
Each round’s merge gets one attempt unless the step declares max_retries
itself, and each round’s rows carry their own iteration, so one round’s
failure never spends another’s budget. The
two ways a join fails — a conflict, and a lane that did not finish — are both
“a human decides”, and an automatic second merge would abort the first, hit the
same conflict and block anyway. The on_conflict: agent resolver below is
pinned to retry_backoff: 0 for the same reason, so a workflow-wide
defaults.retry_backoff does not reach it: its attempts are the join’s own, and
a resolver that does not resolve leaves the conflict for a human rather than
something a wait could fix.
merge and conflicts
| Key | Type | Notes |
|---|---|---|
on_conflict |
block | agent |
Default block |
agent |
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.
agent tries an agent first. It is an ordinary agent step, check and all,
and the conflicted files are in its template context as {{.Conflicts}}. If
it fails, or its check fails, or conflict markers survive it, you get the same
block.
A merge resolver is a full agent step and needs an id, since it gets step-run
rows of its own. It 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.
merge:
on_conflict: agent
agent:
id: resolve
prompt: |
Resolve the merge conflict in: {{ range .Conflicts }}{{.}} {{ end }}
check: go build ./... && go test ./...
A fan-out fills your caps, it does not exceed them. Each lane is a task and competes for
max_parallel_taskslike any other. What it buys is parallelism you would otherwise have to start by hand.
N lanes leave N worktrees on disk until someone archives the tree. That is what
vincent gcandvincent doctorare for, and you will meet it before you meet anything else in this feature.
type: loop
Runs its body — a sequence — repeatedly, in the task’s one worktree. No
branch, no child task, nothing to merge: that is fan_out. Where a parallel
group is a set run once, a loop is a sequence run more than once.
| Key | Type | Required |
|---|---|---|
steps |
list of steps | ✅ |
count |
int | exactly one of count / for_each |
for_each |
string, or list of strings | exactly one of count / for_each |
max_iterations |
int | — (default loop.max_iterations, 10) |
Fix until green — the archetype the feature exists for. Bounded by construction, and post-test by construction, because the condition lives in the body where it can see the body:
- 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.
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.' }
for_each: takes a YAML sequence or a single scalar. Either way each entry is
rendered, trimmed and split on newlines with empty lines dropped, so
[api, web] and a command’s multi-line output are the same mechanism.
There is no while:. A guard can read only what a run has already
produced, and on the first iteration the body has not run — so a while:
reading its own body is either an error or, worse, silently false, and one
reading a step before the loop reads a constant and spins to the ceiling.
count: plus break writes the same loop correctly.
.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 |
What a body may contain
agent, command, condition and break. Rejected at load: manual,
on_input: require, parallel, fan_out and a nested loop — each for the
reason a parallel group rejects it. All of them park or end the task’s
admission mid-body, and a loop’s position is derived from its rows, which
have no way to say “iteration 3 of this loop is waiting on a human”.
Ending a loop
- The driver runs out, or a
breakfires → the loop succeeds and the cursor advances. - A
conditioninside the body is false → that iteration ends and the loop carries on. That iscontinue, and it needs no new step type: a loop body is a sequence, so “end the sequence” ends the pass. - The loop cannot run within
max_iterations→ the task blocks withloop_limit. Afor_eachlist longer than the ceiling blocks before the first iteration, naming the count. - A body step exhausts its retries → the task blocks with that step’s own
reason. Use
allow_failure:when a red probe is the point.
Retries and iterations are different things: max_retries is for a step that
failed, and an iteration is for a body that succeeded and must run again.
Each body step spends its own budget within each iteration.
Human actions
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 it applies to
every remaining iteration — fix the prompt, let it keep going.
The same is true after a crash: position is derived from the rows on every admission, so a restarted daemon resumes mid-iteration rather than redoing work you may have waited an hour for.
A loop is one step: one index, one slot, one worktree, one timeline entry, and its iterations are strictly sequential. Unlike
max_parallel, it adds no concurrency your caps cannot see. What it does add is spend: ten iterations of a three-step body is thirty agent runs, which is why the default ceiling is 10.
type: break
Ends the enclosing loop, successfully. Takes id, name and a required if:,
and nothing else — like condition, it starts no process, so it cannot time
out, be retried or write a transcript.
- { id: passed, type: break, if: '{{ eq (index .Steps "suite").ExitCode 0 }}' }
Only valid inside a loop body: elsewhere there is no loop for it to end.
type: include
Runs another registry workflow’s steps here, in this task and this worktree. It is how a workflow becomes reusable.
| Field | Required | |
|---|---|---|
workflow |
the name of the workflow to include, resolved with the usual project > global > built-in shadowing | ✅ |
# .vincent/workflows/go-checks.yaml
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
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 }}" }
The include disappears. When the task is created, checks is replaced by
lint and test, and the task runs four steps. There is no include at run
time: no step of its own, no grouping, no boundary. That is why review can
read .Steps.lint — after the splice, those steps are feature’s steps.
It also means an include takes no other fields. if, timeout, max_retries,
allow_failure and check are all rejected, because there is no step for them
to bind to. To make an include conditional, guard the callee’s own steps, or
put a type: condition in front of it.
Resolved once, when the task is created. Editing go-checks.yaml
afterwards does not touch a task already running — the task’s snapshot is its
execution truth. It also means everything that can go wrong is reported when
you create the task, not six hours in:
| The workflow is not found | 400 naming it |
A cycle — a includes b includes a |
400 naming the path |
More than include.max_depth levels (default 5) |
400 naming the bound |
| The callee brings a step id already in use | 400 naming both workflows |
The callee’s platforms: excludes this machine |
400 naming both |
Step ids are shared. The whole expansion is one namespace, so a callee
cannot bring an id the caller already uses — and a given workflow can be
included at most once per caller. Ids are never rewritten or prefixed: the
callee’s own templates say .Steps.<id>, and renaming its steps would break
them.
The callee’s defaults: come with it. go-checks above was written with
max_retries: 0, and its steps keep that after being spliced into a workflow
whose defaults say otherwise. The order is
the usual one with the included workflow inserted below
the task: the step’s own field, then the task’s --agent/--model/--effort,
then the included workflow’s defaults, then the including workflow’s, then
the daemon’s.
A condition inside a callee ends the whole run. There is no include
boundary for it to end instead, so a fragment that finishes early finishes the
caller too. And a fragment containing a break cannot be factored out at all,
because break is only valid inside a loop body and so a workflow whose top
level has one does not load.
In the TUI a workflow’s graph shows an include as a single collapsed node
labelled with the workflow it pulls in; a task’s detail view badges each
spliced step with from <workflow>.
Nesting rules
| 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 ✅ everywhere because it is not there when the workflow runs — what
matters is what it expands to, and that is checked against this same table
once the steps are in place. A fragment containing a loop, included into a
loop body, is refused when the task is created with the message a hand-written
nested loop would get.
A lane’s inline steps become a child task’s own flat snapshot, so they are a workflow body in their own right: the “top level” column is the one that applies to them, and their step ids live in their own namespace.
Every ❌ has one cause. A parallel group and a loop iteration run inside a
single admission of a single task, and each rejected type needs a task state
saying “one member of this structure is parked” — which §6 has no room for.
on_input: require is refused inside both for the same reason: awaiting_input
holds one pending request for the whole task. Refusal is judged on the
resolved value, so defaults.on_input: require reaching a silent member is
caught at load.
Conditions
A workflow can decide at run time what to do next. Three fields do it.
if: — skip a step, carry on
Any step may carry a guard. It renders like every other template here and must
produce, after trimming, exactly true or false — nothing else counts, and
yes, 1 and an empty string are all errors rather than a guess.
- 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 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 fan-out lane and on a parallel sub-step, the same if: means
“do not start this one”: the other lanes and sub-steps still run, and the join
still happens. A set has no “later” to skip to, so a false guard subsets it.
Guards are re-evaluated every single time — on each attempt, on a retry, and after a daemon restart. Nothing is cached. If you fix a workflow and retry a blocked step whose guard is now false, the step is skipped, which is the point.
type: condition — finish early
A step whose entire body is the guard. True continues; false ends the run and
the task is done. It takes id, name and if: and nothing else — no
run, no timeout, no max_retries — because it starts no process.
- id: nothing-to-do
type: condition
if: '{{ ne (index .Steps "probe").ExitCode 0 }}'
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 no “stop and block for a human” option, because you already have one:
a command step that exits nonzero. What this adds is stop and succeed.
A condition step is valid at the top level and inside a lane’s workflow. It
is rejected inside a parallel group — a group is a set, so there is no
sequence there to end.
allow_failure: — a failure that is data
Without this, a guard can only read what a human typed when creating the task. A command step that exits nonzero blocks the task, so no step that succeeded 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 — 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 should not retry
- 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. Branching on “the agent is not installed” as though it were a
test result is not a thing a workflow should be able to do.
It is deliberately not available in defaults:. A file-wide “failures do not
block” is a footgun, and it should cost you one line per step that wants it.
Reading the outcome of an earlier step
Guards mostly read .Steps, which after this change carries a little more:
| What | Reads as |
|---|---|
| A step that succeeded | {{ eq (index .Steps "x").Status "succeeded" }} |
| A step skipped by its guard | {{ eq (index .Steps "x").Status "skipped" }} |
A step that failed under allow_failure |
{{ ne (index .Steps "x").ExitCode 0 }} |
| A body step of the loop pass you are in | {{ (index .Steps "probe").Result }} — inside a loop, earlier body steps of the same iteration are visible; a repeated step id resolves to its latest iteration |
| The platform | {{ ne .Host.OS "windows" }} |
| A field typed at creation | {{ eq (index .Task.Fields "ship") "yes" }} |
A step’s own failed attempt is not in .Steps while it is retrying — use
.LastFailure for that.
check is a field, not a step type
It runs after the step body, in the worktree, with the same environment as a command step, and its output is captured to the transcript. Non-zero fails the attempt, 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>
Invert it when failure is the deliverable: check: '! go test ./...'.
Template context
prompt, run, check, instructions and if are Go text/template, rendered
with missingkey=error — a bad reference fails the step before any process
starts.
| Variable | Fields |
|---|---|
.Task |
ID, Title, Description, Fields (map), BaseBranch, BranchName |
.Project |
Name, Path (the original repo root), DefaultBranch |
.Workflow |
Name, Description |
.Step |
ID, Name, Index, Attempt (1-based) |
.Loop |
Index (1-based iteration, 0 outside any loop), Item, IsFirst, IsLast. See type: loop |
.Issue |
the GitHub issue the task was created from: Number (0 when there is none), Repo (owner/name), Title, Body, URL, State, Labels (a list), Author, Assignee, Milestone, MilestoneNumber. See .Issue |
.Steps |
completed steps by id → {Status, Result, ExitCode}. Status is succeeded, approved (a passed gate), skipped (a false guard) or failed — the last only once the workflow has moved past it, which happens only under allow_failure. interrupted never appears |
.Host |
OS, Arch — the machine the daemon runs on. This is the per-step platform gate: {{ ne .Host.OS "windows" }} |
.Worktree |
Path |
.LastFailure |
retries only: {Reason, Output}; empty otherwise |
.Conflicts |
the conflicted file paths, on an on_conflict: agent merge resolver only. Empty everywhere else, so a prompt may read it defensively anywhere |
.Issue
A task can be created from a GitHub issue — from the TUI’s new-task form, or
with vincent task add --github-issue N. When it was, .Issue carries that
issue and a prompt can use it directly:
- id: fix
type: agent
prompt: |
Fix GitHub issue #{{ .Issue.Number }} — {{ .Issue.Title }}
{{ .Issue.Body }}
Labels:{{ range .Issue.Labels }} {{ . }}{{ end }}
Discussion: {{ .Issue.URL }}
.Issue.Number is 0 when no issue is linked, exactly the way .Loop.Index
is 0 outside a loop, so one workflow serves both kinds of task:
prompt: |
{{ if .Issue.Number }}Fix issue #{{ .Issue.Number }}: {{ .Issue.Title }}
{{ else }}{{ .Task.Description }}{{ end }}
Labels is a real list, so range works on it. Everything else is a string.
The issue is a snapshot, read once when the task was created. Nothing
re-reads it, so editing the issue on GitHub afterwards does not change what a
later step renders — and rendering never touches the network, which is why a
step cannot fail here because GitHub is down. A fan-out lane
inherits its parent’s issue, the same way it inherits .Task.Fields.
Creating a task from an issue also prefills the task’s title (#N and the
issue title), its description (the issue body plus a GitHub issue #N: <url>
line) and any declared fields: named exactly issue, labels,
assignee or milestone. All of that is editable before the task is created;
.Issue is the untouched copy.
A declared issue field carries the issue number — bare, without the #
the title carries — and it is how a command step gets at it: step bodies see
§8.5’s environment, not this context, so {{ index .Task.Fields "issue" }} is
the way into a run:. Declare it integer (or number, or string); a
boolean issue is left empty like any other type mismatch.
A task created from a pull request
(--github-pull) fills a declared
field named exactly pull the same way, with the pull request’s number. There is
no .Pull to go with it, on purpose: a pull request is a pointer, never a
snapshot, so nothing about it is stored to re-render — which makes the pull
field the only way a workflow finds the number. labels, assignee and
milestone are deliberately absent from this leg: offering a name it cannot fill
would make the prefill’s shape depend on which leg answered.
Visibility follows one rule — a step appears once the engine has advanced past it — and three consequences follow from it:
- a step’s own failed attempt is never in
.Steps["itself"]mid-retry;.LastFailureis that channel; - members of a
parallelgroup are invisible to each other, since concurrent siblings have never been advanced past; - inside a
loopbody, earlier steps of the current iteration are visible to later ones, and under repetition an id resolves to its latest iteration.
.Steps.<id>.Result is the agent’s final result text for agent steps, or the
last 200 lines of stdout for command steps — stdout only, never stderr.
A command step may therefore print a header, a count or a note for whoever is
watching on stderr without it becoming part of the value, and a step whose
.Result a later prompt quotes must put what that prompt needs on stdout:
git, curl and most tools write their diagnostics to stderr, and those reach
the transcript and the step’s summary but not here. That is how one step’s
output feeds the next — and, with for_each:, how one step’s output becomes a
loop’s item list. A producer meant to feed a loop should filter at the source
rather than lean on that tail:
- id: fix
type: agent
prompt: |
A previous step reported:
{{.Steps.survey.Result}}
Fix exactly those items.
That tail is bounded by 200 lines or 256 KiB, whichever binds first, and it
cuts by dropping whole leading lines: a list over either bound arrives
shorter, never with a half-written item at its edge. Only a single line longer
than 256 KiB on its own is cut mid-line. A for_each: list has to fit both
bounds — a hundred JSON objects of 3 KiB each is 300 KiB, under the line count
and over the byte one, and the earliest of them are what goes.
.Task.Fields remains an open string map per task. A declared optional
field, or an undeclared field that may be absent, must be read defensively or
rendering fails:
{{with index .Task.Fields "ticket"}}Ticket: {{.}}{{end}}
A declared required field may be read directly because task creation rejects a missing value before a snapshot can run.
Environment
Agent, command and check steps run with cwd set to the worktree, inherit the daemon’s environment, and additionally receive:
VINCENT_TASK_ID VINCENT_TASK_TITLE VINCENT_PROJECT_NAME
VINCENT_PROJECT_PATH VINCENT_WORKTREE VINCENT_BRANCH
VINCENT_BASE_BRANCH VINCENT_STEP_ID VINCENT_STEP_ATTEMPT
VINCENT_WORKFLOW
env: on a step is merged on top of these — it is a command-step field, so an
agent step has none. Nothing can remove a VINCENT_* variable: they are facts
about the run, not inherited state.
VINCENT_TASK_ID and VINCENT_STEP_ID are what
vincent status uses to address the step it is being
run from, which is why every step type that runs a process gets them.
Resolution order
For each agent step, agent, model and effort resolve first-hit-wins:
- the explicit step field
- the task-level override chosen at creation (
--agent,--model,--effort) — replaces workflowdefaults, never an explicit step field - the included workflow’s
defaults, for a step that came from atype: include— innermost first when includes nest - the including workflow’s
defaults - the adapter default (usually empty: the CLI decides)
Agent-scoped inheritance: 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.
POST /v1/resolve answers “what does this resolve to, and which level won” for
every step — that is what the TUI’s new-task form renders, and no client
re-implements the precedence. For a file the registry has not picked up yet,
vincent workflow render <file> answers the same question offline, from the
same resolver.
Validation rules
vincent workflow validate <file> checks all of this locally — no daemon, no
network, no agent CLI installed. Two values it cannot ask a daemon for, and
substitutes deterministically so a file that validates on a laptop validates on
a bare CI runner:
- agent catalogs are the curated, compiled-in ones rather than a live
claude --help. 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.
One thing it cannot check at all: whether a type: include
resolves. Which file a name reaches depends on the project’s registry, and a
project workflow may shadow the very name that was missing or that closed a
cycle — so includes are resolved and checked when a task is created, and a
400 there is the report.
Exit status is 1 for an invalid file; --json emits the findings structurally.
Validation parses templates; it never runs them. A {{.Task.Titel}}, or a
{{.Task.Fields.ticket}} on a task that sets no ticket, is valid YAML with a
valid template in it and fails when the step renders.
vincent workflow render <file> executes
them against a preview context — locally, with no daemon either — and is the
other half of the same pre-commit check.
stepsnon-empty; stepids unique across the whole file, sub-steps of aparallelgroup included;typeknown.- A
parallelgroup has at least one sub-step and amax_parallelof at least 1; its sub-steps are notmanual, notparallel, notcondition, and do not resolve toon_input: require. - A
conditionstep has anif:and nothing else —timeout,max_retriesandallow_failureincluded.allow_failureis valid onagentandcommandsteps only. Aconditionstep in last position is a warning: the task is done whether it continues or stops. - A
loophas at least one body step and exactly one driver:count(at least 1, and at most the effective ceiling — the step’s ownmax_iterations, elseloop.max_iterations) orfor_each. Its body steps join the file-wide id namespace, and are notmanual,parallel,fan_outor anotherloop, and do not resolve toon_input: require. Alooprejectsmax_retriesandallow_failure: it has no attempt of its own. Abreakhas anif:and nothing else, and is valid only inside a loop body.count,for_eachandmax_iterationsare rejected on every other step type. - A
fan_outstep has at least one lane; lane ids are unique within the step and are slugs; each lane has exactly one ofworkflowandsteps.merge.agentis required by, and only valid with,on_conflict: agent; it is a full agent step, needs anid, and may not declareon_input: require. A lane’s inline steps have their own id namespace, because each lane becomes its own task.resolved_fromis written by task creation and is an error by hand, and so isderived_from— written by the daemon when a derived lane list is rendered into the task’s snapshot, so that a reader can tell a derived list from a hand-written one afterwards. Neither appears inGET /v1/workflows/schema, so the structured editor never offers a control for them. - An
includestep has aworkflowand nothing else.workflowis rejected on every other type, andresolved_from— written by task creation — is an error beside it. Whether the name resolves is not checked here: see the note above. - Every template parses.
platformsentries are known tokens, with no duplicates. The list is checked for shape, never against the validating host.- Durations parse as Go durations;
on_inputiswait,denyorrequire. - Unknown keys are errors.
agentnames a known adapter.- A step with
on_input: requiredoes not resolve to an adapter that can never take mid-run input (codex, cursor). The error points at theagentfield that supplied the value — the step’s own, ordefaults.agent. - The resolved
(agent, model, effort)triple is checked cross-catalog:- a value in the resolved adapter’s own catalog → valid;
- a value found only in another adapter’s catalog → error (claude’s
sonnetormaxreaching a codex step); - a value in no catalog at all → warning, and it passes. The CLI is the final authority at run time, so free-text models and future CLI values are not blocked.
Validation never probes an agent CLI. It consults the live option cache when the daemon has primed it and the curated catalogs otherwise — probing only ever adds values, so a verdict can soften but never harden.
Warnings surface structurally: on registry entries, on the validate response, on the task-creation response, and in the daemon log.
See also
- Writing workflows — the guide.
- Example workflows — five working files.
- Agent CLIs — what each adapter honors.