Automations
End-user automations: #[trigger], #[condition] and #[action] blocks with JSON Schemas for an editor, versioned definitions per workspace, enrollments, simulation and templates.
On this page
Introduction
Featureworkflows Your customers want to automate their own work: "when a deal is won, create a thank-you task, wait a day, then a call for big deals or a survey for the rest". Each customer wants a different sequence, so you can't write it as code. You can write the pieces.
Automations split the job in two:
- You write blocks. A trigger (an event that starts an automation), a condition (a branch) or an action (something done) is a Rust type plus a function, with
#[trigger],#[condition]or#[action]. The type is the block's settings and derivesToSchema, so an editor gets a JSON Schema for its form. - People combine them. An automation is a definition: a trigger and a graph of nodes (actions, conditions, delays and waits). People edit a draft in your editor, check it, simulate it with a sample event, and publish it as a version.
- Every matching event enrolls. An enrollment is a durable workflow run that walks the published version's nodes: actions run once each with retries, delays sleep for days without holding a worker, waits end on the subject's next event or a timeout. Enrollments keep their version when the automation changes.
You get the Automations service, the catalog of blocks, validation with JSON pointers, enrollment policies, simulation, templates, the /api/automations routes for an editor and the automations:* commands. Automations belong to a workspace, or to the whole app.
Installation
Automations come with WorkflowsModule (the workflows feature, in full); see the workflows installation. Block settings derive utoipa's ToSchema, so add utoipa too:
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree" }
utoipa = { version = "6", features = ["chrono", "uuid"] }They need DatabaseModule (for the automations, automation_versions and automation_enrollments tables) and EventsModule (triggers are events). workflows.automations = false turns them off: no event enrolls, and the routes are not mounted.
Triggers
A trigger is an event and the settings a person picks for it. Write the settings type, then a function that decides which events fire an automation with those settings:
use navree::prelude::*;
use navree::workflows::{AutomationCtx, action, condition, trigger};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// A deal was won.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DealWon {
pub deal_id: Uuid,
pub owner_id: Uuid,
pub amount_cents: i64,
pub workspace_id: Option<Uuid>,
}
impl Event for DealWon {
const NAME: &'static str = "deal.won";
}
/// The settings of the "Deal won" trigger.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DealWonTrigger {
/// Only deals worth at least this much, in cents.
#[serde(default)]
pub min_amount_cents: Option<i64>,
}
/// A deal moved to won.
#[trigger(type = "deal.won", label = "Deal won", subject = "owner_id")]
fn deal_won(config: &DealWonTrigger, event: &DealWon) -> bool {
config
.min_amount_cents
.is_none_or(|min| event.amount_cents >= min)
}A trigger that fires on every event of its type needs no function: put the attribute on the settings struct and name the event:
/// A task was completed.
#[trigger(type = "task.completed", label = "Task completed", event = TaskCompleted, subject = "owner_id")]
#[derive(Serialize, Deserialize, ToSchema)]
pub struct TaskCompletedTrigger {}| Argument | Default | Meaning |
|---|---|---|
type = ".." | required | The stable type name stored in definitions (deal.won) |
label = ".." | the type name | The name the editor shows |
subject = ".." | none | The event field naming who or what is enrolled (owner_id): enrollment policies and waits use it |
workspace = ".." or false | "workspace_id" | The event field holding the workspace id, or false when the event has none |
event = Type | the function's second argument | The event, for the attribute on a struct |
Fields are dotted paths into the event's JSON (deal.owner_id). The doc comment becomes the block's description. The function is a plain fn (it runs for every event of the type), and its settings may be empty: a node without config reads as {}.
Conditions
A condition branches: it decides between a node's then and else paths from what the enrollment knows.
/// The deal is worth at least the amount.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct DealAmountAtLeast {
/// The amount, in cents.
pub amount_cents: i64,
}
/// The won deal is worth at least the amount.
#[condition(type = "deal.amount_at_least", label = "Deal amount")]
async fn deal_amount_at_least(
config: &DealAmountAtLeast,
ctx: &AutomationCtx,
) -> Result<bool, BoxError> {
let deal: DealWon = ctx.event_as()?;
Ok(deal.amount_cents >= config.amount_cents)
}The function takes the settings and the AutomationCtx by reference, may be async or not, and returns bool or Result<bool, E>. An enrollment evaluates it once and stores the answer. Simulations evaluate it too, so keep it free of side effects.
The built-in condition field compares a value of the enrollment's data with a constant, with no code:
{"id": "big-deal", "kind": "condition", "type": "field",
"config": {"path": "event.amount_cents", "op": "gte", "value": 1000000},
"then": "call", "else": "survey"}op is one of eq, ne, gt, gte, lt, lte (numbers, or strings in byte order), contains (a text holds the value, or a list holds it), in (the value, a list, holds the field), exists and missing. A missing field never compares.
Actions
An action does something: create a record, send a message, call a service.
/// The settings of the "Create a task" action.
#[derive(Serialize, Deserialize, ToSchema)]
pub struct CreateTask {
/// The task's title.
pub title: String,
}
/// What the action returns: later nodes read it as `nodes.<id>.task_id`.
#[derive(Serialize)]
pub struct TaskCreated {
pub task_id: Uuid,
}
/// Creates a task for the deal's owner.
#[action(type = "task.create", label = "Create a task", preview = create_task_preview)]
async fn create_task(config: CreateTask, ctx: AutomationCtx) -> Result<TaskCreated, BoxError> {
let tasks = ctx.app().get::<Tasks>().await?;
let deal: DealWon = ctx.event_as()?;
let task_id = tasks
.create(deal.owner_id, &config.title, &ctx.idempotency_key())
.await?;
Ok(TaskCreated { task_id })
}
/// What a simulation shows instead of creating the task.
fn create_task_preview(config: &CreateTask, ctx: &AutomationCtx) -> serde_json::Value {
json!({ "title": config.title, "owner_id": ctx.subject() })
}The function takes the settings and the AutomationCtx by value and returns Result<T, E>; T is stored with the enrollment. An action runs as a durable step: once per enrollment, with 3 attempts on errors, and never during a simulation, which shows preview instead (by default, the settings). ctx.idempotency_key() is unique to the node of the enrollment (<run id>:<node id>): pass it to services that deduplicate, since an action whose process dies runs again.
Each of the three attributes implements a trait (Trigger, Condition, Action) on the settings type; you can write the impl yourself instead. A missing type, a function of the wrong shape or an unknown key is a compile error.
What blocks know
AutomationCtx carries the enrollment's data:
| Method | |
|---|---|
event(), event_as::<E>() | The trigger's event, as JSON or as a type |
subject() | Who or what is enrolled (the trigger's subject field), as text |
field("event.amount_cents") | A value of the data by dotted path |
output_of("thank") | What node thank returned (an action's output, a condition's answer, a wait's event) |
data() | Everything: { "event", "subject", "workspace_id", "automation": { "id", "version" }, "nodes": { .. } } |
automation_id(), version(), enrollment_id(), run_id(), node(), workspace_id() | Where the block runs |
is_simulation(), idempotency_key(), app() | The simulation flag, a key for the node, the app |
Registering blocks
Register blocks and templates in the module that owns them:
#[module(register = Self::automations)]
pub struct CrmAutomationsModule;
impl CrmAutomationsModule {
fn automations(&self, m: &mut ModuleBuilder) {
m.trigger::<DealWonTrigger>()
.trigger::<TaskCompletedTrigger>()
.condition::<DealAmountAtLeast>()
.action::<CreateTask>()
.automation_template(
AutomationTemplate::new(
"big-deal-follow-up",
"Big deal follow-up",
big_deal_follow_up(),
)
.description("Thanks the owner, then a call or a survey a day later.")
.category("Sales")
.enrollment(EnrollmentPolicy::EveryTime),
);
}
}m.trigger::<T>() also subscribes the engine to the trigger's event. Two types with one type name fail the boot, naming both.
The catalog
GET /api/automations/catalog is your editor's palette: every block with its JSON Schema, and the node kinds.
{
"triggers": [
{
"kind": "trigger",
"type": "deal.won",
"label": "Deal won",
"description": "A deal moved to won.",
"event": "deal.won",
"subject": "owner_id",
"schema": {
"type": "object",
"description": "The settings of the \"Deal won\" trigger.",
"properties": {
"min_amount_cents": {
"type": ["integer", "null"],
"format": "int64",
"description": "Only deals worth at least this much, in cents."
}
}
}
}
],
"conditions": [ { "kind": "condition", "type": "deal.amount_at_least", "…": "…" }, { "kind": "condition", "type": "field", "…": "…" } ],
"actions": [ { "kind": "action", "type": "task.create", "…": "…" } ],
"nodes": ["action", "condition", "delay", "wait"]
}Render each block's schema as its settings form; field doc comments are the descriptions. In code, automations.catalog() lists the same (triggers(), conditions(), actions(), block(kind, type)).
Definitions
A definition is a trigger and a list of nodes. The first node runs first:
{
"trigger": {"type": "deal.won", "config": {}},
"nodes": [
{"id": "thank", "kind": "action", "type": "task.create",
"config": {"title": "Thank the customer"}, "next": "a-day-later"},
{"id": "a-day-later", "kind": "delay", "seconds": 86400, "next": "big-deal"},
{"id": "big-deal", "kind": "condition", "type": "deal.amount_at_least",
"config": {"amount_cents": 1000000}, "then": "call", "else": "survey"},
{"id": "call", "kind": "action", "type": "task.create",
"config": {"title": "Call the customer"}},
{"id": "survey", "kind": "action", "type": "task.create",
"config": {"title": "Send the survey"}}
],
"ui": {"zoom": 1.5}
}| Kind | Fields | Does |
|---|---|---|
action | type, config, next | Runs the action |
condition | type, config, then, else | Evaluates the condition and branches |
delay | seconds (at least 1), next | Sleeps, durably |
wait | trigger (type, config), same_subject (default true), timeout_seconds, next, on_timeout | Waits for a trigger's event (see waits) |
A node without a target ends the enrollment. Node ids are unique, 1 to 100 characters, without # and not starting with $; later nodes read a node's output as nodes.<id>. Nodes may not loop: each runs at most once per enrollment. ui fields, on the definition or on a node, are kept as they are, for your editor's own data (positions, notes). A definition has at most 200 nodes.
Validation. automations.validate(&definition) and POST /api/automations/validate check a definition against the catalog and list every problem at a JSON pointer:
{
"valid": false,
"issues": [
{ "path": "/nodes/0/type", "message": "unknown action `task.make`" },
{ "path": "/nodes/1/seconds", "message": "a delay lasts at least 1 second" },
{ "path": "/nodes/2/config", "message": "missing field `amount_cents`" },
{ "path": "/nodes/4/next", "message": "going to `thank` makes a loop: each node runs at most once" }
]
}It finds unknown blocks, settings their type does not accept, duplicate or invalid ids, targets that do not exist, loops, a zero delay or timeout, and a same_subject wait on a trigger without a subject.
Drafts, publishing and versions
An automation has a draft definition and, once published, versions:
let draft = automations
.create(
&uow,
NewAutomation::new("Big deal follow-up", definition)
.workspace(workspace.id)
.description("Thanks, then a call or a survey.")
.enrollment(EnrollmentPolicy::EveryTime),
)
.await?;
let published = automations.publish(&uow, draft.id, Some(user.id)).await?;createstores a draft (status: draft). The definition must have the right shape (a 400 otherwise); what it names is checked when it is published, so people can save work in progress.automations.details(id)returns the automation with its draft'sissues.update(&uow, id, AutomationChanges { .. })changes the name, description, draft definition or enrollment policy. The published version keeps enrolling until the next publish.publishchecks the draft against the catalog (a 400validation_failedlisting the issues), stores it as the next version and makes the automationactive. From then on, matching events enroll in that version.pauseandresumestop and restart enrolling (409automation_not_activeorautomation_not_pausedotherwise). Enrollments under way go on.deleteremoves the automation with its versions and enrollments, and cancels the enrollments under way.
The writes run in your unit of work, and reads inside it see them: an update and a publish in one request publish the update. versions(id) lists the published versions, newest first, with who published them.
Enrollment
When an event of a published trigger is emitted, every active automation listening to it is checked:
- The workspace. An automation of a workspace only enrolls events whose workspace field holds that workspace. App-wide automations enroll every event.
- The trigger's settings. The trigger function decides (
min_amount_centsabove). - The enrollment policy, by the event's subject:
| Policy | Enrolls |
|---|---|
one_at_a_time (default) | Unless the subject's earlier enrollment is still under way |
once | Only the first time, ever, per subject |
every_time | On every event |
Events without a subject (a trigger with no subject) always enroll. An event emitted inside a unit of work (a handler's) enrolls once that work commits, and not at all if it rolls back. Each enrollment is stored with its run in one transaction, then runs: inline in the emit without a queue, as workflows.run jobs with one. Its actions, delays and waits behave as workflow steps: an action that fails three times fails the enrollment with the action's error. An enrollment's run belongs to the automation's workspace, and its output is every node's output by id.
automations.enrollments(id, page) lists an automation's enrollments with the status of their runs; enrollment(id) reads one and cancel_enrollment(id) cancels it (409 enrollment_finished when it finished). enroll(id, event_json) enrolls by hand, in the published version, whatever the status, trigger settings and policy (a "run it for this record now" button); the JSON must be the trigger's event (400 invalid_automation_event), and an automation never published is a 409 automation_not_published.
Waits
A wait node waits for a trigger's event, by default the enrolled subject's next one:
{"id": "next-task", "kind": "wait", "trigger": {"type": "task.completed"},
"timeout_seconds": 259200, "next": "thanks", "on_timeout": "chase"}When the deal's owner completes a task within three days, the enrollment goes on at thanks, and the event is the node's output (nodes.next-task). Otherwise it goes on at chase after three days. The trigger's settings and function filter the events too. same_subject: false takes any event the trigger accepts; without timeout_seconds the wait has no end.
Simulation
A simulation shows what an event would do without storing or running anything:
POST /api/automations/0199a1c2-…/simulate
Content-Type: application/json
{"event": {"deal_id": "…", "owner_id": "0199…", "amount_cents": 2500000, "workspace_id": null}}{
"enrolls": false,
"reason": "the automation is draft",
"subject": "0199…",
"version": null,
"steps": [
{ "node": "thank", "kind": "action", "type": "task.create", "at_seconds": 0, "outcome": "would_run",
"detail": { "title": "Thank the customer", "owner_id": "0199…" } },
{ "node": "a-day-later", "kind": "delay", "at_seconds": 0, "outcome": "waits", "detail": { "seconds": 86400 } },
{ "node": "big-deal", "kind": "condition", "type": "deal.amount_at_least", "at_seconds": 86400, "outcome": "true", "detail": null },
{ "node": "call", "kind": "action", "type": "task.create", "at_seconds": 86400, "outcome": "would_run",
"detail": { "title": "Call the customer", "owner_id": "0199…" } }
]
}enrollsandreasonsay whether the event would enroll now (a draft or paused automation, another workspace, the trigger's settings, the policy).- Each step is a node reached: actions show their preview (
would_run), conditions their answer, delays and timeouts add up inat_seconds. - The body takes
version(simulate a published version instead of the draft) andwaits:received(the default, waits get their event) ortimed_out(waits take theiron_timeoutpath). - The event must be the trigger's event (400
invalid_automation_event); an unknown version is a 400unknown_automation_version.
automations.simulate(id, SimulateInput { event, version, waits }) does the same in code.
Templates
Templates are ready-made definitions people start from. Register them with m.automation_template(..) (above); they are checked against the catalog at boot, so a template naming a block that does not exist fails the boot. GET /api/automations/templates lists them, and POST /api/automations/templates/{key} (or automations.create_from_template(&uow, key, workspace, name)) creates a draft from one, named after the template unless the body names it:
POST /api/automations/templates/big-deal-follow-up
Content-Type: application/json
{"name": "Our follow-up"}Over HTTP
With workflows.automation_routes on (the default), the module mounts the routes of an editor under {http.api_prefix}/automations. Reads need automations.view, changes automations.manage:
| Route | Permission | Answer |
|---|---|---|
GET /api/automations/catalog | view | The blocks with their schemas, and the node kinds |
GET /api/automations/templates | view | { "data": [templates] } |
POST /api/automations/templates/{key} | manage | 201: a draft from the template |
POST /api/automations/validate | view | { "valid", "issues" } for { "definition" } |
GET /api/automations?page=&per_page= | view | A page of automations, newest first |
POST /api/automations | manage | 201: a draft from { "name", "description", "definition", "enrollment" } |
GET /api/automations/{id} | view | The automation with its draft's issues |
PATCH /api/automations/{id} | manage | The automation, changed |
DELETE /api/automations/{id} | manage | 204 |
POST /api/automations/{id}/publish | manage | The automation, active on its new version |
POST /api/automations/{id}/pause, /resume | manage | The automation |
GET /api/automations/{id}/versions | view | { "data": [versions] }, newest first |
POST /api/automations/{id}/simulate | view | The simulation |
POST /api/automations/{id}/enroll | manage | 201: the enrollment of { "event" } |
GET /api/automations/{id}/enrollments | view | A page of enrollments with their status |
GET /api/automations/{id}/enrollments/{enrollment} | view | One enrollment |
POST /api/automations/{id}/enrollments/{enrollment}/cancel | manage | The enrollment, cancelled |
In an app with WorkspaceModule, the routes need a workspace (X-Workspace): they list, create and change that workspace's automations only (another workspace's is a 404), and publishing records the signed-in user as published_by. Without it, they act on app-wide automations.
{
"data": [
{
"id": "0199a1d0-…",
"automation_id": "0199a1c2-…",
"version": 1,
"workspace_id": null,
"subject": "0199…",
"run_id": "0199a1d0-…",
"status": "sleeping",
"error": null,
"created_at": "2026-10-26T08:00:00Z",
"finished_at": null
}
],
"meta": { "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 }
}An enrollment's run_id is a workflow run: GET /api/workflows/runs/{run_id} shows every node it went through.
From the command line
| Command | |
|---|---|
automations:list | Automations with their status and published version |
automations:catalog | The registered triggers, conditions and actions |
The enrollments' runs are workflow runs: workflows:list --workflow automation lists them.
Testing
Automations run on the same engine as workflows, so the workflow test setup runs them: a fixed clock, no queue (enrollments run inline in the emit), and tick() after moving the clock.
#[tokio::test]
async fn won_deals_get_a_thank_you_task() {
let app = app_with_fixed_clock().await; // DatabaseModule, EventsModule, WorkflowsModule, CrmAutomationsModule
let automations = app.get::<Automations>().await;
let db = app.get::<Db>().await;
let uow = db.uow(None, None).await;
let draft = automations
.create_from_template(&uow, "big-deal-follow-up", None, None)
.await
.unwrap();
automations.publish(&uow, draft.id, None).await.unwrap();
uow.commit().await.unwrap();
let owner = Uuid::now_v7();
let deal = DealWon { deal_id: Uuid::now_v7(), owner_id: owner, amount_cents: 2_500_000, workspace_id: None };
let simulation = automations
.simulate(draft.id, SimulateInput { event: serde_json::to_value(&deal).unwrap(), ..SimulateInput::default() })
.await
.unwrap();
let path: Vec<&str> = simulation.steps.iter().map(|s| s.node.as_str()).collect();
assert_eq!(path, ["thank", "a-day-later", "big-deal", "call"]);
app.get::<EventBus>().await.emit(deal).await.unwrap();
// ... move the clock a day, then `workflows.tick()`: the call task exists.
}The routes take the usual HTTP tests: grant automations.view and automations.manage to the user you act as.
Reference
Automations method | |
|---|---|
catalog(), templates(), template(key) | The registered blocks and templates |
validate(&definition) | Validation { valid, issues } |
create(&uow, new), create_from_template(&uow, key, workspace, name) | A draft |
get(id), details(id), list(&query), page(scope, page), versions(id) | Reads |
update(&uow, id, changes), publish(&uow, id, by), pause, resume, delete | Changes |
simulate(id, input) | A dry run |
enroll(id, event), enrollments(id, page), enrollment(id), cancel_enrollment(id) | Enrollments |
WorkflowsError | Response |
|---|---|
UnknownAutomation, UnknownEnrollment, UnknownTemplate | 404 automation_not_found, enrollment_not_found, automation_template_not_found |
InvalidDefinition | 400 validation_failed, with errors by JSON pointer |
Invalid | 400 invalid_automation_event, unknown_automation_version |
Conflict | 409 automation_not_active, automation_not_paused, automation_not_published, enrollment_finished |