Skip to content

Automations

An automation wraps a single workflow in quality gates and a feedback-driven retry loop. Where a workflow just runs, an automation makes it reliable: it validates the input before the workflow starts, checks the output after it finishes, and — when the output isn't good enough — re-runs the workflow with the failures fed back in, until the checks pass or a retry cap is reached.

Think of an automation as a contract around a workflow:

  1. Validate the input. If it doesn't meet your preconditions, the workflow never runs.
  2. Run the wrapped workflow.
  3. Check the output — including which tools the workflow actually called.
  4. If the output checks fail, retry the workflow with the failed checks injected as feedback.
  5. Stop when the output checks pass, or when the retry cap is hit — at which point on_exhaust decides whether the run fails or returns its best effort.

One automation wraps exactly one workflow.

What runs an automation

An automation doesn't run on its own — it is invoked by a trigger in response to an event. Declaring an automation defines what runs and how it's gated; a trigger decides when it fires.


Declaring an automation

An automation is a YAML file in your module's automations/ folder, registered through the automations: key in module.yaml (a list of file paths, just like workflows: and data:). Each listed file becomes one automation when the module is installed.

mymodule/
  module.yaml
  workflows/
    invoice_followup.yaml
  automations/
    dunning.yaml
name: mymodule
version: 0.1.0
requires: [base]
models: [models]
workflows: [workflows/invoice_followup.yaml]
automations: [automations/dunning.yaml]   # list of automation YAML file paths
data: [data.xml]

Note

Automations are owned by the installing module. When the module is uninstalled, its automations are removed automatically — you never clean them up by hand.


Automation schema

A complete automation names the workflow it runs, sets its retry policy, and declares its input and output gates:

name: dunning
label: Dunning
workflow: sendish            # name of the workflow this automation runs
max_iterations: 3           # retry cap when output checks fail (default 3)
on_exhaust: fail            # fail | best_effort (default fail)
input_checks:
  - { expr: "input.invoice_id", message: "invoice_id required" }
output_checks:
  - { expr: "output.status == 'sent'", message: "must be sent" }

Top-level keys

Key Type Required Default Description
name string Yes Technical id of the automation, unique within the module.
label string No Human-readable label.
workflow string Yes Name of the workflow this automation runs. Must reference a workflow that exists (resolved at install time).
max_iterations integer No 3 Retry cap: how many times the workflow may run while output checks keep failing. Must be >= 1.
on_exhaust fail | best_effort No fail What happens when the cap is reached without passing all output checks.
input_checks list of checks No The input gate, evaluated before the workflow runs.
output_checks list of checks No The output gate, evaluated after the workflow returns.

Both input_checks and output_checks are lists of checks, and each check is a { expr, message } pair:

Field Required Description
expr Yes The assertion. A bare expression (see Writing checks). If it raises or evaluates to a falsy value, the check fails.
message No Human description of the check, used as the failure feedback text when the check fails.

You can write checks inline ({ expr: "...", message: "..." }) or in block style — they are equivalent:

input_checks:
  - expr: "input.invoice_id"
    message: "invoice_id is required"
  - expr: "input.invoice_id > 0"
    message: "invoice_id must be positive"

The input gate

input_checks run before the workflow, against a context that exposes input — the input dict passed to the automation. If any input check fails, the workflow does not run and the automation run is rejected.

Use the input gate for preconditions the workflow depends on:

name: needs_input
workflow: demo
input_checks:
  - { expr: "input.invoice_id", message: "invoice_id required" }

Here, if input.invoice_id is missing or falsy, demo never starts.


The output gate

output_checks run after the workflow returns, against a context that exposes two variables:

Variable Description
output The value the workflow returned. e.g. output.status == 'sent'.
trace A record of what the workflow did during the run.

trace lets you assert not just what the workflow produced but how:

Field Type Description
trace.nodes_run list The step ids that executed during the run.
trace.tools_called list The tool names the workflow invoked — so a check can assert a specific tool actually ran.

For example, to require that the workflow sent the email (not just that it said it did):

output_checks:
  - expr: "output.status == 'sent'"
    message: "the dunning email must be sent"
  - expr: "'send_email' in trace.tools_called"
    message: "send_email tool must be called"

If every output check passes, the automation succeeds and returns the workflow's output. If any output check fails, the automation enters its retry loop.


Writing checks

Checks use the same restricted expression evaluator as workflows. Expressions are bare — you do not wrap them in {{ }}. An expression that raises, or that evaluates to a falsy value, fails its check.

Category Allowed Example
Access attribute and key access input.invoice_id, output['status'], trace.tools_called
Comparison > < == != >= <= output.status == 'sent'
Boolean and or not (short-circuit) input.invoice_id and output.status
Membership in not in 'send_email' in trace.tools_called
Arithmetic + - * / input.amount * input.quantity
Grouping parentheses (a or b) and c

Tip

Because a check fails when its expression is falsy, a bare input.invoice_id doubles as a "this key is present and truthy" assertion — you don't need input.invoice_id != None.


The retry loop

When output checks fail, the automation re-runs the wrapped workflow — but this time it injects the failures as feedback so the workflow can adjust. On each retry the automation sets:

input.feedback:
  failed_checks: [ ... ]      # the messages of the output checks that failed
  previous_output: ...        # the workflow's output from the previous attempt

A workflow opts into this by reading input.feedback. A workflow that ignores it will simply keep producing the same output and exhaust the retry cap.

name: invoice_followup
label: Invoice Follow-up
input:
  invoice_id: int
  feedback: object       # optional; populated by the automation on retry

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

  - id: maybe_send
    run: tool:maybe_send_email
    with:
      to: "{{ fetch.contact_email }}"
      feedback: "{{ input.feedback }}"   # tool uses this to adjust behavior on retry

output: "{{ maybe_send.result }}"

The loop continues until either the output checks pass, or max_iterations is reached.

Design the workflow to react to feedback

The retry loop only improves results if the workflow actually uses input.feedback. On the first attempt input.feedback is absent; from the second attempt on it carries the messages of the checks that failed and the prior output. Give a feedback-aware step something concrete to do with those failure messages.


When the retry cap is hit: on_exhaust

If max_iterations runs go by and the output checks still don't all pass, on_exhaust decides the outcome:

Value Behavior
fail (default) The automation run fails.
best_effort The automation succeeds and returns the last output anyway.

Use best_effort when a partial or imperfect result is still useful and you'd rather have it than nothing:

name: stuck_best
workflow: stuck
max_iterations: 2
on_exhaust: best_effort
output_checks:
  - { expr: "output.status == 'sent'", message: "must be sent" }

This automation runs stuck up to twice; if output.status is still not 'sent', it returns the last output rather than failing.


Complete example

A dunning automation that gates its input, runs an invoice follow-up workflow, and requires both that the result reports sent and that the send_email tool was actually called — retrying up to three times, then failing if it can't get there:

name: dunning
label: Dunning Run
workflow: invoice_followup
max_iterations: 3
on_exhaust: fail

input_checks:                          # evaluated against { input }
  - expr: "input.invoice_id"
    message: "invoice_id is required"
  - expr: "input.invoice_id > 0"
    message: "invoice_id must be positive"

output_checks:                         # evaluated against { output, trace }
  - expr: "output.status == 'sent'"
    message: "the dunning email must be sent"
  - expr: "'send_email' in trace.tools_called"
    message: "send_email tool must be called"

Registered in module.yaml:

name: mymodule
version: 0.1.0
requires: [base]
models: [models]
workflows: [workflows/invoice_followup.yaml]
automations: [automations/dunning.yaml]

Lifecycle and ownership

  • An automation is created when its module is installed, and each file listed under automations: becomes one automation.
  • The workflow: reference is resolved at install time — the named workflow must exist (typically declared in the same module or a dependency).
  • Automations are owned by the installing module and are removed automatically when that module is uninstalled.

See also

  • Workflows — the ordered, step-based processes an automation wraps, and the shared expression/templating rules.
  • Triggers — declaring event-driven reactions in a module.