Skip to content

Workflows

A workflow is a declarative, multi-step process your module defines in YAML. It is an ordered sequence of steps (nodes); each step either runs a tool (deterministic) or an agent (autonomous), and steps can branch conditionally between one another.

Workflows are pure data. There is no embedded code — any custom logic ships as a tool that a step references by name. That makes a workflow easy to read, version in your module's git repo, and reason about: it is a description of what happens and in what order, not an implementation.

Reach for a workflow when a task is more than a single tool call — for example "read the invoice, decide whether it is overdue, then either escalate to an agent or send a reminder email." Each of those is a step; the branching between them is expressed with conditions.

Note

A workflow orchestrates tools and agents you have already defined in your module. Define those first, then wire them together here.

How a workflow is run

This page covers how to author a workflow. A defined workflow does not run on its own — it is invoked by a caller that supplies its input values. In Victor that caller is an automation: one automation wraps exactly one workflow, adding input/output checks and retries around it. The automation is in turn set off by a trigger — on a schedule, an inbound webhook, or manually. So the usual path is: author the workflow here, wrap it in an automation, and point a trigger at that automation.

Where workflow files live

Workflow YAML files live in a workflows/ directory inside your module, one file per workflow. Register them with the workflows: key in your module.yaml — a list of paths relative to the module root, the same list-of-paths shape used by other manifest keys.

# module.yaml
name: mymodule
version: 0.1.0
requires: [base]
workflows: [workflows/invoice_followup.yaml]   # list of workflow YAML paths

On disk:

mymodule/
  module.yaml
  workflows/
    invoice_followup.yaml

Add more workflows by dropping more files in workflows/ and listing each path:

# module.yaml
workflows: [workflows/demo.yaml, workflows/sendish.yaml]

The workflow schema

Every workflow file has the same top-level shape.

Field Required Type Description
name yes string Technical id, unique within the module.
label no string Human-readable label.
input no object (name: type) Declares the workflow's input parameters. Values passed at run time are exposed as input.<name>.
steps yes list of node objects The ordered list of nodes. Document order defines the default "next" step.
output no string ({{ }} expression) Evaluated when the workflow terminates to produce its explicit result. Omit it and the workflow returns null.

A minimal workflow declares an input, runs one tool step, and returns an output expression:

# workflows/sendish.yaml
name: sendish
label: Sendish
steps:
  - id: send
    run: tool:maybe_send
    with: { feedback: "{{ input.feedback }}" }
output: "{{ send }}"

Declaring input

input is an optional schema object mapping each parameter name to a type. The verified examples use int and string:

input:
  invoice_id: int
  priority: string

At run time, the caller — usually an automation wrapping this workflow — supplies values for these parameters, and every step can read them as input.<name> (for example input.invoice_id).

The output expression

output is explicit — it is not implicitly the last step's result. Victor evaluates it against the final run context when the workflow terminates. It is optional: a workflow with no output runs to completion and returns null. When you do declare it, a common pattern is to fall back across mutually exclusive branches:

output: "{{ big.note or small.note }}"

Whichever branch actually ran wrote its result under its id; the other id resolves to a falsy value, so or picks the one that executed.

Node kinds

steps is a list of nodes. There are two kinds, distinguished by which fields they carry:

  • A node with run: is a run node — it invokes a tool or an agent.
  • A node with if: is a condition node — it evaluates an expression and branches.

Every node, of either kind, has an id.

Run nodes

A run node executes one tool or one agent and stores its result in the context under the node's id.

Field Required Description
id yes Node identifier, unique within the workflow. The node writes its output into the context under this id.
run yes What to invoke: tool:<name> or agent:<name>.
with no Params passed to the tool/agent. Keys are param names; values are templates/expressions using {{ }}.
goto no Overrides the default successor: a target node id, or the reserved value end. Omit to fall through to the next step in document order.
- id: escalate
  run: agent:escalation_manager
  with:
    invoice_id: "{{ input.invoice_id }}"
    amount: "{{ fetch.total }}"
    days_overdue: "{{ fetch.days_overdue }}"
  goto: end

Use run: tool:<name> for deterministic steps and run: agent:<name> when you want an agent to handle the step autonomously. In both cases, with supplies the parameters.

What a run node writes depends on what it runs

A run node stores whatever its target returns. A tool returns whatever its code returns — often a keyed object, so later steps read a field off it (add.sum, big.note). An agent always writes its text answer as a plain string, so you reference the node id directly (escalate), never a field. Reading a field off a plain string (e.g. escalate.result) fails, because a string has no such field.

Tip

Most with values are {{ }} templates that pull from the context, but a value can also be a plain YAML literal — including a list. In the full example below, fields: [total, contact_email, days_overdue] is passed as a literal list.

Condition nodes

A condition node routes to one of two nodes based on a boolean expression.

Field Required Description
id yes Node identifier, unique within the workflow.
if yes A bare expression (no braces) evaluated against the context.
then yes Target node id, or end, taken when if is true.
else yes Target node id, or end, taken when if is false.
- id: decide
  if: "add.sum > 10"        # bare expression, no braces
  then: big
  else: small

if is not templated

The if condition is a bare expression evaluated directly — do not wrap it in {{ }}. Braces are for with: values and the output: expression only.

Data flow: the run context

A workflow run walks over a single context dictionary. Its shape is:

{
  "input": { ... },          # the values passed to the workflow
  "<node_id>": <output>,     # each node writes its result under its id
  ...
}

Each node writes its result into the context under its id. Later steps read those values three ways:

  • In with: values — via {{ }} templates, e.g. "{{ fetch.total }}".
  • In if: conditions — as a bare expression, e.g. fetch.days_overdue > 30.
  • In the output: expression — via {{ }}, e.g. "{{ escalate or send_reminder }}".

Templating vs. bare expressions

Location Form Example
with: values {{ }} template a: "{{ input.a }}"
output: {{ }} template "{{ big.note or small.note }}"
if: bare expression add.sum > 10

Supported operations

Expressions support a restricted, safe set of operations:

Category Operators / forms
Attribute/key access fetch.total, input.invoice_id, input['invoice_id']
Comparisons > < == != >= <=
Membership in, not in (e.g. fetch.status in ["open", "pending"])
Boolean logic and or not (short-circuit)
Arithmetic + - * /
List/tuple literals ["open", "pending"], (1, 2) — handy as the right-hand side of in
Grouping parentheses ( )

These expressions are for reading and comparing context values, not for arbitrary computation — anything beyond the set above (function calls, string methods, chained comparisons, and so on) is not available. Put real logic in a tool and reference it from a run node.

Flow and transition semantics

Control moves through the steps according to these rules:

  • Default successor is the next step in document order.
  • goto on a run node overrides the default successor. Its value is a node id, or the reserved end.
  • A condition node routes to then when if is true, and to else when it is false. Each is a node id or end.
  • end is a reserved successor value (usable in goto, then, and else). It terminates the workflow and triggers evaluation of the output expression.

Branch tails must not fall through

Because the default successor is the next step in document order, the last step of a branch will fall through into the sibling branch that follows it — usually not what you want. Make a branch tail either the final step in the file or end it with goto: end.

This branching workflow shows the pattern: a tool step, a condition, and two branches. The big branch ends with goto: end so it does not fall through into small; the small branch is the last step, so it terminates naturally.

# workflows/demo.yaml
name: demo
label: Demo
steps:
  - id: add
    run: tool:calc_add
    with: { a: "{{ input.a }}", b: "{{ input.b }}" }
  - id: decide
    if: "add.sum > 10"        # bare expression, no braces
    then: big
    else: small
  - id: big
    run: tool:calc_note
    with: { text: "big" }
    goto: end                 # jump to termination; skip the sibling branch
  - id: small
    run: tool:calc_note
    with: { text: "small" }   # last step -> falls through to end
output: "{{ big.note or small.note }}"

A complete example

Putting it together: a workflow that declares an input, reads records with a tool, branches on a condition, and either hands off to an agent or sends a reminder email — with templated params throughout and an explicit output.

# workflows/invoice_followup.yaml
name: invoice_followup
label: Invoice Follow-up
input:
  invoice_id: int

steps:
  - id: fetch
    run: tool:read_records
    with:
      query: "invoice_id = {{ input.invoice_id }}"
      fields: [total, contact_email, days_overdue]

  - id: decide
    if: "fetch.days_overdue > 30"
    then: escalate
    else: send_reminder

  - id: escalate
    run: agent:escalation_manager
    with:
      invoice_id: "{{ input.invoice_id }}"
      amount: "{{ fetch.total }}"
      days_overdue: "{{ fetch.days_overdue }}"
    goto: end

  - id: send_reminder
    run: tool:send_email
    with:
      to: "{{ fetch.contact_email }}"
      subject: "Payment reminder"
      body: "Your invoice #{{ input.invoice_id }} for ${{ fetch.total }} is now {{ fetch.days_overdue }} days overdue."

output: "{{ escalate or send_reminder }}"

Reading it top to bottom:

  1. fetch runs a tool with the declared input.invoice_id and writes its result under fetch.
  2. decide compares fetch.days_overdue against 30 and routes to escalate or send_reminder.
  3. escalate hands off to an agent, then goto: end so it does not fall into send_reminder.
  4. send_reminder is the final step and terminates naturally.
  5. output returns whichever branch ran (escalate or send_reminder). escalate is an agent, so its result is a plain string referenced by id — no field access — while the unrun branch's slot is empty (falsy), so or picks the one that executed.

Checklist

  • [ ] Each workflow file lives in workflows/ and is listed under workflows: in module.yaml.
  • [ ] Every workflow has name and steps; add an explicit output when the run should return a value (omit it and the run returns null).
  • [ ] Every node has a unique id; run nodes have run, condition nodes have if/then/else.
  • [ ] if: is a bare expression; with: values and output: use {{ }}.
  • [ ] Each branch tail is the final step or ends with goto: end.
  • [ ] Every tool:/agent: referenced by a run node exists in your module.
  • Automations — wrap a workflow in input/output checks and retries; this is what actually runs a workflow and supplies its input.
  • Triggers — fire an automation on a schedule, an inbound webhook, or manually.
  • Tools — the deterministic units a run: tool:<name> step invokes.
  • Agents — the autonomous handlers a run: agent:<name> step invokes.
  • Models & fields — the data your tools read and write.
  • Views — how records are presented in the UI.