# Workflows

> Graph-based automation: branch, fan out, loop, and route on agent decisions

Workflows are graph-based automations. Where an [automation](/platform/triggers) runs a flat, linear list of actions, a workflow is a **trigger node plus a directed graph** of nodes that can branch, fan out, iterate over a collection, and route on an agent's structured decision. The shape is familiar if you have used n8n, but with a deliberately stricter data model.

Workflows are authored as code with the [`defineWorkflow`](/sdk/define-workflow) builder in the `workflows/` directory and synced via the CLI exactly like agents, routers, and automations. The dashboard renders a read-only canvas and a run inspector; code is the only authoring surface.

> Workflows supersede linear automations. New automations that need branching, fan-out, loops, or agent-driven routing should be built as workflows. Linear automations remain supported. See [Automations](/platform/triggers).

## A workflow at a glance

```typescript
import { workflow } from 'struere'

const wf = workflow({ name: "Friday Follow-up", slug: "friday-followup" })

wf
  .onCron({ schedule: "0 9 * * 5", timezone: "America/Santiago" })
  .query("entity.query", { type: "lead", filters: { stage: "hot" }, limit: 1000 }, { name: "Hot leads" })
  .forEach({ throttleMs: 1200 }, (lead) => {
    lead.tool("whatsapp.sendTemplate", {
      to: "{{ $item.data.phone }}",
      templateName: "friday_followup",
      language: "es",
      components: [],
    }, { name: "Send follow-up" })
  })

export default wf.build()
```

This fires every Friday at 9am, queries all hot leads, and sends each one a WhatsApp template — throttled to respect rate limits.

## Node kinds

A workflow node is one of three kinds, identified by its `type` namespace:

| Kind | `type` prefix | Examples |
|------|---------------|----------|
| **Trigger** | `trigger.` | `trigger.entity`, `trigger.cron`, `trigger.manual` |
| **Action** | `tool.` | `tool.entity.query`, `tool.whatsapp.sendTemplate`, `tool.agent.chat`, any custom tool |
| **Flow-control** | `flow.` | `flow.if`, `flow.switch`, `flow.foreach`, `flow.splitout`, `flow.aggregate` |

A workflow has **exactly one trigger node** (it has no input and a single output port that emits the first item). Every built-in tool and every custom tool becomes an action node automatically — there is no per-tool wiring.

### Trigger nodes

| Trigger | Fires on | Emits (the `$trigger` item) |
|---------|----------|------------------------------|
| `trigger.entity` | An entity `created` / `updated` / `deleted` mutation | `{ entityId, entityType, action, data, previousData }` |
| `trigger.cron` | A recurring cron schedule | `{ firedAt }` |
| `trigger.manual` | A manual fire (CLI / dashboard) | the fire payload |

### Flow-control nodes

| Node | Input | Output ports | Purpose |
|------|-------|--------------|---------|
| `flow.if` | one | `true` = 0, `false` = 1 | Route one item to one of two branches |
| `flow.switch` | one | `case0..caseN-1`, then `default` | Route one item to a matching case branch |
| `flow.foreach` | one (with `over`) or many | `loop` = 0, `done` = 1 | Iterate a collection; the body runs once per item |
| `flow.splitout` | one | `out` = 0 (many) | Take one item whose field is an array; emit one item per element |
| `flow.aggregate` | many | `out` = 0 (one) | Collapse many items back into one |

## The items model

Workflows process **one item per node**. An item is `{ json: object }`. There is no implicit mapping over an array — iteration is explicit and is the job of a single construct, `flow.foreach`. The only nodes that legally receive more than one item are `flow.foreach` and `flow.aggregate`.

This makes "one item per node" enforceable at sync time rather than discovered at runtime, and removes the n8n-style ambiguity where a node silently fans over an items array.

## Cardinality

Every action node's output is statically `one` or `many`:

- **`many` outputs:** `entity.query`, `event.query`, `web.search`, `calendar.list`, `calendar.freeBusy`, `airtable.listRecords`, `whatsapp.listTemplates`.
- **`many` flow outputs:** `flow.splitout`, and the `done` port of `flow.foreach`.
- **`one` outputs:** everything else — `entity.create/get/update/delete`, `whatsapp.send/sendTemplate/sendMedia`, `email.send`, `payment.create/getStatus`, `calendar.create/update/delete`, `web.fetch`, `agent.chat`, `router.transfer`, and all custom tools (which return a single result object).

**The cardinality rule (enforced at sync):** an edge originating from a `many` output may target **only** a `flow.foreach` or a `flow.aggregate`. Connecting a `many` output to anything else is a hard save error:

```
'Hot leads' emits many items; connect it to a ForEach or Aggregate
```

A `flow.foreach` consuming a `many` stream must **not** set `over` (it iterates the stream directly). Setting `over` is for iterating an array field of a single item. This is why `entity.query → flow.foreach` is the canonical "iterate a collection" pattern.

## Ports

Multi-output nodes route items by **port**. The output ports are fixed:

| Node | Ports (index → name) |
|------|----------------------|
| `flow.if` | 0 → `true`, 1 → `false` |
| `flow.foreach` | 0 → `loop`, 1 → `done` |
| `flow.switch` | 0..N-1 → each case, N → `default` |
| `flow.splitout` | 0 → `out` (many) |
| `flow.aggregate` | 0 → `out` (one) |
| action node | 0 → `out` (one or many per the tool) |
| trigger node | 0 → `out` |

The `done` port of a ForEach emits the collected per-iteration results as a `many` stream — so it must feed an Aggregate, or be left unconnected if the results are unused.

## Expressions

Any string value in a node's params can contain `{{ ... }}` expressions, resolved at execution time. Workflows expose an n8n-style root vocabulary:

| Expression | Resolves to |
|------------|-------------|
| `{{ $json.field }}` | A field on the **current input item's** `json` |
| `{{ $trigger.field }}` | A field on the trigger item (stable for the whole run) |
| `{{ $item.field }}` | Inside a ForEach body, the current loop item's `json` |
| `{{ $node["Node Name"].json.field }}` | A field on a referenced ancestor node's output item |
| `{{ $now }}`, `{{ $runId }}` | Run metadata |

For entity triggers, `{{ $trigger.data.X }}` and `{{ $trigger.previousData.X }}` give the new and prior values. Expressions support both dot and bracket notation; a node name containing spaces requires the bracket form (`$node["Classify VIP"]`, not `$node.Classify VIP`).

Expressions are **path lookups plus comparison operators only** — there is no arbitrary code execution. An expression that matches a field exactly returns the raw value (so `{{ $json.attendees }}` stays an array, not a string).

### Node references

`{{ $node["X"] }}` is legal **only if X runs on every path that reaches the referencing node** — i.e. X dominates it in the graph. This is checked at sync:

- Referencing a node on a **different branch** (not an ancestor) is a save error: `node 'Standard thanks' references $node["VIP perk"], which is not on its path`.
- Referencing a **non-existent / misspelled** node name is a save error.

For valid graphs the referenced node is guaranteed to have run, so runtime resolution never throws; a missing field simply resolves to `undefined`. In the [builder](/sdk/define-workflow), a node handle's `.ref()` makes this a compile-time guarantee — you cannot express a reference to a non-dominating node.

## Triggers in depth

### Entity triggers — match and transitions

`trigger.entity` covers both plain events and field transitions:

- **`match`** — an AND of equalities on `data` (`created`/`updated`) or `previousData` (`deleted`). Every key/value must equal for the workflow to fire.
- **`transitions`** — for `updated` only, each `{ field, from?, to? }` matches when, in this mutation, `previousData[field]` was `from`, `data[field]` is `to`, and the value actually changed. `from`/`to` accept an array for same-field membership (e.g. `from: ["pending", "hold"]`). Omitting `from` or `to` matches any prior/new value.

`match` and `transitions` must both hold. `transitions` on a `created`/`deleted` trigger is a save error.

```typescript
wf.onEntity({
  entityType: "payment",
  action: "updated",
  transitions: [{ field: "status", from: ["pending", "hold"], to: "confirmed" }],
})
```

### Cron triggers

`onCron({ schedule, timezone? })` uses a standard 5-field cron expression. Cron workflows are checked every minute. `timezone` accepts any IANA identifier; if omitted, the schedule runs in UTC.

```
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, 0=Sunday)
│ │ │ │ │
* * * * *
```

### Manual triggers

`onManual({ inputSchema? })` fires only when fired explicitly (via `struere workflows fire` or the dashboard). `inputSchema` shapes the fire form's payload.

### Concurrency

Each trigger carries a concurrency policy, read at run start:

- **`dedupeKey`** — an expression resolved against the trigger item. Defaults to the entity `entityId` (entity triggers) or the workflow slug (cron/manual).
- **`concurrencyMode`** — one of:
  - **`parallel`** (default): every match starts a run.
  - **`dropIfRunning`**: if a `pending`/`running` run exists for the same `(workflow, dedupeKey)`, the new match is dropped.
  - **`cancelPrevious`**: cancel the in-flight run(s) for that key, then start the new one.

```typescript
wf.onEntity({
  entityType: "payment",
  action: "updated",
  transitions: [{ field: "status", from: "pending", to: "confirmed" }],
  dedupeKey: "{{ $json.entityId }}",
  concurrencyMode: "dropIfRunning",
})
```

## Agent nodes

An agent is a first-class node: `tool.agent.chat`. With an `output.schema`, the agent emits a **structured object** as its item, and a Switch or IF binds to a typed field — robust branching on an LLM decision, with no free-text parsing.

```typescript
const classify = item.agent({
  agent: "support-classifier",
  message: "Classify this request: {{ $json.message }}",
  output: {
    schema: {
      type: "object",
      properties: {
        isRefund: { type: "boolean" },
        category: { type: "string", enum: ["refund", "booking", "other"] },
      },
      required: ["isRefund", "category"],
    },
  },
  name: "Classify request",
})

classify.switch({
  value: classify.ref(".json.category"),
  cases: [
    { value: "refund", then: (h) => h.tool("agent.chat", { agent: "refunds", message: "Handle refund" }, { name: "Refund flow" }) },
    { value: "booking", then: (h) => h.tool("agent.chat", { agent: "bookings", message: "Handle booking" }, { name: "Booking flow" }) },
  ],
  fallback: (h) => h.tool("agent.chat", { agent: "support", message: "Handle generic" }, { name: "Generic flow" }),
})
```

An agent node with `output.schema` requires a **structured-output-capable model** on the referenced agent (validated at sync). Without `output.schema`, the agent node behaves like a normal agent call and emits `{ text }`.

## Execution model

Workflows run on a **durable, persisted step-machine**. Each step is one scheduled invocation that pops the next ready node, executes it, routes its output, persists, and schedules the next step. A deploy, crash, or retry resumes mid-graph at the failed branch — there is no long-running process.

| Property | Behavior |
|----------|----------|
| Actor | Nodes run as the privileged **system actor** (full permissions, no thread needed) |
| Retry | **Per-node**, with exponential backoff `min(backoffMs · 2^(attempts-1), 1h)`; an exhausted node goes `dead` |
| Failure isolation | A `dead` node kills only its own downstream branch; sibling fan-out branches still complete |
| Idempotency | Side-effecting nodes carry an engine-derived key so retries are exactly-once in effect |
| ForEach | **Sequential**, with `throttleMs` between iterations and `continueOnItemError` (default `true`) |
| Cascade | Entity writes from a workflow do **not** trigger other workflows by default |

### Run statuses

```
pending → running → ┬─ completed
                    ├─ completed_with_errors   (≥1 branch dead, others ok)
                    ├─ failed                  (terminal error)
                    └─ dead                    (cancelled / all paths dead)
```

| Status | Meaning |
|--------|---------|
| `pending` | Created, not yet started |
| `running` | Advancing through the graph |
| `completed` | Every node succeeded |
| `completed_with_errors` | At least one branch went dead while others completed |
| `failed` | A terminal, non-branch error |
| `dead` | Cancelled, or no surviving path |

Individual nodes have their own status: `running`, `success`, `error`, `dead`, `skipped`.

### No cascade by default

Entity writes performed by a workflow node set `skipWorkflows: true`, so a workflow's `entity.create/update/delete` does **not** re-trigger other workflows. This is the safe default (e.g. writing a result back onto the triggering entity). Opt in per node with `cascade: true`; cascading is bounded by a depth/budget cap to prevent runaway loops.

## Coexistence with linear automations

Workflows ship alongside [linear automations](/platform/triggers) — both fire independently from the same entity mutation. The plan automation limit counts workflows and automations together. To migrate an existing automation, generate a workflow file from it with `struere workflows import-trigger <slug>`, review the generated file, and sync it. The source automation is left intact and can be disabled separately.

## Worked example A — cron → query → ForEach

Every Friday at 9am, message every hot lead. `entity.query` is a `many` output, so it must feed a ForEach; the ForEach's `done` port is left unconnected because the per-send results are unused. `throttleMs` keeps sends under WhatsApp rate limits.

```typescript
import { workflow } from 'struere'

const wf = workflow({ name: "Friday Follow-up", slug: "friday-followup" })

wf
  .onCron({ schedule: "0 9 * * 5", timezone: "America/Santiago" })
  .query("entity.query", { type: "lead", filters: { stage: "hot" }, limit: 1000 }, { name: "Hot leads" })
  .forEach({ batchSize: 1, throttleMs: 1200, continueOnItemError: true }, (lead) => {
    lead.tool("whatsapp.sendTemplate", {
      to: "{{ $item.data.phone }}",
      templateName: "friday_followup",
      language: "es",
      components: [],
    }, { name: "Send follow-up" })
  })

export default wf.build()
```

A run over 300 leads is 300 sequential iterations, each a small scheduled step — run-state size stays constant regardless of collection size.

## Worked example B — transition trigger → fan-out → agent-driven branch

A payment transitioning `pending → confirmed` fans out to three independent branches: notify the customer, notify ops, and (load the customer → classify VIP with structured output → route to a VIP perk or a standard thank-you).

```typescript
import { workflow } from 'struere'

const wf = workflow({ name: "VIP payment flow", slug: "vip-payment" })

wf
  .onEntity({
    entityType: "payment",
    action: "updated",
    transitions: [{ field: "status", from: ["pending", "hold"], to: "confirmed" }],
    dedupeKey: "{{ $json.entityId }}",
    concurrencyMode: "dropIfRunning",
  })
  .fanOut(
    (b) => b.tool("whatsapp.sendTemplate", {
      to: "{{ $trigger.data.phone }}", templateName: "payment_received", language: "es", components: [],
    }, { name: "Notify customer" }),
    (b) => b.tool("whatsapp.sendTemplate", {
      to: "{{ $trigger.data.opsPhone }}", templateName: "ops_new_payment", language: "es", components: [],
    }, { name: "Notify ops" }),
    (b) => b
      .query("entity.query", { type: "customer", filters: { id: "{{ $trigger.data.customerId }}" }, limit: 1 }, { name: "Load customer" })
      .forEach({ batchSize: 1, continueOnItemError: false }, (customer) => {
        const classify = customer.agent({
          agent: "vip-classifier",
          message: "Is this customer VIP given lifetime value {{ $item.data.ltv }}?",
          output: { schema: { type: "object", properties: { isVip: { type: "boolean" } }, required: ["isVip"] } },
          name: "Classify VIP",
        })
        classify.if(
          { left: classify.ref(".json.isVip"), operator: "truthy" },
          {
            whenTrue: (h) => h.tool("entity.create", {
              type: "perk", data: { customerId: "{{ $trigger.data.customerId }}", kind: "vip_gift" },
            }, { name: "VIP perk" }),
            whenFalse: (h) => h.tool("whatsapp.sendTemplate", {
              to: "{{ $trigger.data.phone }}", templateName: "thanks_standard", language: "es", components: [],
            }, { name: "Standard thanks" }),
          },
        )
      }),
  )

export default wf.build()
```

If the classify branch's agent fails after exhausting retries, only that branch goes dead — the two notifications already completed and are never re-sent, and the run finishes `completed_with_errors`. `concurrencyMode: "dropIfRunning"` with `dedupeKey` = the payment id means a second confirmation for the same payment while one is in flight is dropped.

## Authoring rules

These are validated at sync and **block** the sync if violated:

| Rule | Description |
|------|-------------|
| Exactly one trigger | A workflow has one trigger node with no incoming edges |
| Unique names / ids | Node names and ids are unique within the workflow |
| Acyclic | The graph contains no cycle (ForEach loops are engine-internal, never authored as back-edges) |
| Tree / in-degree ≤ 1 | No node has two incoming edges — merge (wait-for-all) is a v2 feature |
| Cardinality | A `many` output edge targets only a `flow.foreach` (without `over`) or `flow.aggregate` |
| Node references | Every `$node["X"]` ref points to an existing node that dominates the referencing node |
| Transitions | `transitions` only on entity triggers with `action: "updated"` |
| Structured-output model | An `agent.chat` node with `output.schema` references an agent on a structured-output-capable model |

## Inspecting and operating runs

Use the [`struere workflows`](/cli/workflows) CLI to list workflows, view a run's per-node timeline, fire a workflow, and cancel or retry runs. The dashboard provides a read-only canvas with a live run inspector and admin-gated fire / cancel / retry controls.
