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:
- Validate the input. If it doesn't meet your preconditions, the workflow never runs.
- Run the wrapped workflow.
- Check the output — including which tools the workflow actually called.
- If the output checks fail, retry the workflow with the failed checks injected as feedback.
- Stop when the output checks pass, or when the retry cap is hit — at which point
on_exhaustdecides 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.
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.