Flow steps and expressions
For: plugin authors.
Look up step types, error branches and expression behaviour.
Steps
Each step has an id (lowercase letters, digits and _, so expressions can read it as steps.<id>) and does one thing:
| Step | Does | Its output, in steps.<id> |
|---|---|---|
tool: <tool>.<function> with input | Calls a tool function on the flow's home node, with the tenant's hooks and approvals | The tool's structured result, or its text (parsed when it is JSON) |
agent: <name> with input | Runs an agent as a child job and waits for its answer | The agent's answer |
approval: with details and timeout | Waits for a person; no answer within timeout (default 24 h) is a no | {approved, by, comment} |
output: <name> with input | Queues a routine's safe output, which the edge checks and applies after the flow by default. An output with apply: now is checked and applied during the step; only a routine's fire may run it | {queued, message}, or {applied, state, result, answer} for apply: now |
wait_for_event: with source, types, subject, filter, timeout | Waits for an event (1 h by default, 7 d at most) | {matched, event} |
An agent step may also have best_of:, which runs the agent as several candidates and keeps one; see Outcome checks and patterns.
A built-in in a step
A tool: step may name one of three platform built-ins instead of a plugin's function, and the runtime runs it with the
step's rendered input, without a model:
| Built-in | Does | Its output |
|---|---|---|
oz.report_outcome | Records a downstream result on a finished job of the flow's own plugin | {recorded}, or {recorded: false, reason} |
oz.evidence | Reads what a granted plugin's jobs came to, or where it is installed from | The read's answer, or {refused} |
oz.propose_update | Files a change request to update an installed plugin, which a person approves | {filed, change, delta_sha256, commit, message}, or {filed: false, reason} |
steps:
- id: merged
tool: oz.report_outcome
input: {job: "${{ steps.job.job }}", outcome: success, name: merged}
- id: update
when: steps.merged.recorded
tool: oz.propose_update
input: {plugin: notes, note: "${{ 'Pull request ' + string(input.number) + ' was merged.' }}"}
Each runs exactly as when an agent calls it: the same checks at the edge (which reads what to do from the job's own history), the tenant's tool policy rules for its name (a refusal fails the step, an approval rule waits for a person), a routine's tool list (a fire calls only the built-ins its routine lists) and the audit. The step's rendered input of oz.report_outcome and oz.propose_update is scanned for the values of the tenant's secrets before any of these decide it, so one that carries a secret fails the step and never reaches an approval card.
A shadow run stubs oz.report_outcome and oz.propose_update. Use a step rather than an agent for a call the flow already decides: a model would add cost, and a model the flow's input could steer. plugin lint accepts these three names in a step; any other oz. name is not a function another plugin provides.
when: makes a step conditional; a step whose condition is false is skipped and has no output, which has(steps.<id>) tells. Steps run in order. A step that fails, or an approval that is denied, ends the flow with the step's ID in the error, unless a later step reads its failure (error branches).
The flow's answer is output:, or the last step's output when there is none.
Error branches
An agent, tool or approval step that fails does so with a failure kind: its agent's own, or the platform's (tool_failed for a tool call, denied for an approval nobody granted). A later step whose when: reads steps.<id>.failure handles it, and the flow goes on instead of ending:
steps:
- id: look
agent: triage # declares not_found and tracker_busy
input: ${{ input.ticket }}
- id: label
tool: github.add_label
input: {issue: "${{ input.ticket }}", label: "${{ steps.look.team }}"}
- id: missing
when: "steps.look.failure.kind == 'not_found'"
tool: github.comment
input: {issue: "${{ input.ticket }}", body: "No such issue: ${{ steps.look.failure.message }}"}
- id: retry_later
when: steps.look.failure.retryable
tool: github.add_label
input: {issue: "${{ input.ticket }}", label: triage-again}
- id: report
tool: chat.post
input: {text: "Triage of ${{ input.ticket }} done"}
- What it reads.
steps.<id>.failureholdskind,retryable,partial,person,declaredandmessage(the agent's words for a kind it declares). A failed step's output is{failure: ...}, sosteps.<id>.failureis the only part a later step can read of it.
What runs
While a failure is open, only a step whose when: reads it may run: label above is skipped when look fails. The first step that reads it and runs handles it, and the steps after it run as usual (report). A step whose when: reads the failure of a step that did not fail is skipped. If that step's output has a field named failure of its own, steps.<id>.failure is that field when the step succeeded, as it always was, and the when: decides; plugin lint warns about such a flow, since the same words then mean two things.
- Cancelling the flow. A flow cancelled while a step runs ends cancelled: the step's cancellation is not a failure a later step handles, and no step runs after it. A job that delegates ends the same way when it is cancelled while its child runs.
- When nothing handles it. A failure that no later step's
when:reads ends the flow as before, with the step's ID in the error, and its kind in the job'sfailure. So does one that later steps read but whose conditions are all false, once the flow reaches its end. - What fails with a kind. Agent, tool and approval steps. An expression that cannot be evaluated, a safe output that cannot be queued and a wait for an event that fails end the flow at once;
plugin lintwarns about awhen:that reads the failure of a step that cannot fail with a kind.
A child's words that came without its labels taint the flow, as a flow tool's do.
Expressions
Expressions are CEL. They see input (the flow's input), steps (the outputs of the steps so far), job (the job's ID), trigger (for a webhook, its headers and body; null otherwise) and uses (the other plugins the flow's plugin may reach now, each true).
when:is one expression, true or false.- Everywhere else, a value is literal unless it contains
${{ ... }}. A string that is exactly one${{ expression }}becomes the expression's value, with its type:input: ${{ input.ticket }}passes the whole ticket. Other strings get each expression's value filled in where it stands, however many they hold:agent: acme-${{ steps.classify.team }}, or two in one string, as in this approval:- id: confirm approval: details: "${{ steps.look.amount }} EUR back to ${{ input.customer }}?"
With an amount of 40 and the customerAda, the person approving reads40 EUR back to Ada?. A map or a list filled into a string is written as JSON, with the keys of maps sorted. An expression ends at the first}}outside its quotes and its own braces, so${{ {'a': {'b': 1}} }}and${{ '}}' }}are each one expression.
Mind two things:
- Quote what YAML would misread. An expression with
:or a leading{needs quotes, like theoutput:above. - CEL is strict about types.
2.0 * 3is an error; write2.0 * 3.0. Comparing an int with a double (1 >= 0.8) is fine.
plugin lint checks every expression, and that each steps.<id> names an earlier step, so a mistake fails the install, not the first run.
Steps of an optional plugin
A plugin that works with whichever of several systems a tenant has marks them optional: true under
uses:. A tenant may then lack one, or not have granted it, or have revoked it, so
each step that calls it says so with uses, and the step is skipped instead of failing the job. uses holds a plugin
while any of its granted functions or agents is in reach: after a revoke of only some of them it is still there, and
a step that calls a revoked function fails the job as an ungranted call does.
steps:
- id: read_sn
when: '"servicenow" in uses && input.system == "servicenow"'
tool: servicenow.get_incident
input: {number: "${{ input.ticket }}"}
- id: read_dc
when: '"atlassian-dc" in uses && input.system == "jira-dc"'
tool: atlassian_dc_jira.get_issue
input: {issueIdOrKey: "${{ input.ticket }}"}
Write it as "<plugin>" in uses (a plugin's name may hold a -, which CEL would read as a minus). plugin lint
warns about a step that calls an optional plugin's tool or agent without such a when:, and about a when: that asks
for a plugin uses: does not list. A recording of the flow's steps
keeps the flow's uses when it was recorded, and its replay reads them instead of the tenant's grants, so a flow eval
takes the same path in any tenant.