Write a flow

Flow steps and expressions

Look up step types, error branches and expression behaviour.

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:

StepDoesIts output, in steps.<id>
tool: <tool>.<function> with inputCalls a tool function on the flow's home node, with the tenant's hooks and approvalsThe tool's structured result, or its text (parsed when it is JSON)
agent: <name> with inputRuns an agent as a child job and waits for its answerThe agent's answer
approval: with details and timeoutWaits for a person; no answer within timeout (default 24 h) is a no{approved, by, comment}
output: <name> with inputQueues 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, timeoutWaits 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-inDoesIts output
oz.report_outcomeRecords a downstream result on a finished job of the flow's own plugin{recorded}, or {recorded: false, reason}
oz.evidenceReads what a granted plugin's jobs came to, or where it is installed fromThe read's answer, or {refused}
oz.propose_updateFiles a change request to update an installed plugin, which a person approves{filed, change, delta_sha256, commit, message}, or {filed: false, reason}
flows/after-merge.yaml
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:

flows/triage.yaml
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>.failure holds kind, retryable, partial, person, declared and message (the agent's words for a kind it declares). A failed step's output is {failure: ...}, so steps.<id>.failure is 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's failure. 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 lint warns about a when: 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 customer Ada, the person approving reads 40 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 the output: above.
  • CEL is strict about types. 2.0 * 3 is an error; write 2.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.

flows/support-triage.yaml
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.

Copyright © 2026