# defineWorkflow

> Author graph-based automations with a typed fluent builder

Workflows are graph-based automations: a single trigger node plus a directed graph of action and flow-control nodes. Unlike [automations](/sdk/define-trigger) (which run a flat, linear list of actions), workflows can branch, fan out, loop over collections, and route on an agent's structured decision. Each workflow lives in its own file under the `workflows/` directory and is synced via the CLI exactly like agents, routers, and automations.

For the conceptual model (node kinds, cardinality, ports, expressions, execution semantics) see [Workflows](/platform/workflows). This page is the SDK reference.

```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()
```

## Two ways to author a workflow

There are two entry points, both exported from `struere`:

| Entry point | Use it for |
|-------------|-----------|
| `workflow(meta)` | The fluent **builder** — the primary, human-facing authoring surface. Returns a `WorkflowBuilder`. |
| `defineWorkflow(config)` | The raw `WorkflowConfig` form (`nodes[]` + `connections{}`). Used by code generation (e.g. `workflows import-trigger`); humans rarely write it by hand. |

The builder is preferred because it auto-assigns node ids, derives the `connections{}` graph from the call tree, enforces unique node names, and encodes cardinality in the type system. The raw form is its compile target.

## The builder

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

const wf = workflow({ name: "...", slug: "...", description: "..." })
```

`workflow(meta)` returns a `WorkflowBuilder`. Declare exactly one trigger to get a handle to the rest of the graph, chain nodes onto it, then call `.build()`.

| `meta` field | Type | Required | Description |
|--------------|------|----------|-------------|
| `name` | `string` | Yes | Display name |
| `slug` | `string` | Yes | Unique identifier (workflows upsert by slug) |
| `description` | `string` | No | Human-readable description |

### Triggers

A workflow must declare **exactly one** trigger. Each trigger method returns a `OneHandle` representing the trigger's emitted item.

```typescript
onEntity(opts: {
  entityType: string
  action: "created" | "updated" | "deleted"
  match?: Record<string, unknown>
  transitions?: { field: string; from?: unknown | unknown[]; to?: unknown | unknown[] }[]
  dedupeKey?: string
  concurrencyMode?: "parallel" | "dropIfRunning" | "cancelPrevious"
}): OneHandle

onCron(opts: {
  schedule: string
  timezone?: string
  dedupeKey?: string
  concurrencyMode?: "parallel" | "dropIfRunning" | "cancelPrevious"
}): OneHandle

onManual(opts?: {
  inputSchema?: JSONSchema
  dedupeKey?: string
  concurrencyMode?: "parallel" | "dropIfRunning" | "cancelPrevious"
}): OneHandle
```

| Method | Trigger node | Emits (`$trigger`) |
|--------|--------------|--------------------|
| `onEntity` | `trigger.entity` | `{ entityId, entityType, action, data, previousData }` |
| `onCron` | `trigger.cron` | `{ firedAt }` |
| `onManual` | `trigger.manual` | the fire-form payload |

`concurrencyMode` defaults to `"parallel"`. `dedupeKey` is an expression resolved against the trigger item; it defaults to the trigger `entityId` (entity) or the workflow slug (cron/manual). See [Concurrency](/platform/workflows#concurrency) for the modes.

`transitions` are only valid when `action` is `"updated"` — the builder throws at build time otherwise.

### OneHandle

A `OneHandle` represents one node's single output item. Every method extends the graph from that node.

```typescript
interface OneHandle {
  tool(type: string, params: Record<string, unknown>, opts?: { name?: string }): OneHandle
  query(type: ManyTool, params: Record<string, unknown>, opts?: { name?: string }): ManyHandle
  agent(opts: {
    agent: string
    message: string
    context?: Record<string, unknown>
    output?: { schema: JSONSchema }
    name?: string
  }): OneHandle
  splitOut(field: string, opts?: { name?: string }): ManyHandle
  forEach(
    opts: { over: string; batchSize?: number; throttleMs?: number; continueOnItemError?: boolean; name?: string },
    body: (loop: OneHandle) => void,
  ): ManyHandle
  if(
    opts: { left: string; operator: string; right?: unknown; name?: string },
    branches: { whenTrue: (h: OneHandle) => void; whenFalse?: (h: OneHandle) => void },
  ): void
  switch(opts: {
    value: string
    cases: { value: unknown; then: (h: OneHandle) => void }[]
    fallback?: (h: OneHandle) => void
    name?: string
  }): void
  fanOut(...branches: Array<(h: OneHandle) => void>): void
  ref(path?: string): string
}
```

| Method | Adds node | Returns | Notes |
|--------|-----------|---------|-------|
| `tool` | `tool.<type>` (any `"one"`-output tool or custom tool) | `OneHandle` | Runs the tool once on the input item |
| `query` | `tool.<type>` (a `"many"`-output tool) | `ManyHandle` | `type` is restricted to the many-output tool set; the resulting `ManyHandle` can only feed `.forEach` / `.aggregate` |
| `agent` | `tool.agent.chat` | `OneHandle` | Pass `output.schema` for structured output (see below) |
| `splitOut` | `flow.splitout` | `ManyHandle` | `field` resolves to an array; emits one item per element |
| `forEach` | `flow.foreach` | `ManyHandle` (the `done` port) | Requires `over` (an expression resolving to an array on the single input item); `body` wires the loop body |
| `if` | `flow.if` | `void` | `branches.whenTrue` / `whenFalse` wire each port |
| `switch` | `flow.switch` | `void` | `cases[i].then` wires case port `i`; `fallback` wires the default port |
| `fanOut` | (no node) | `void` | Wires multiple independent branches from the same output port (parallel, never rejoin) |
| `ref` | (no node) | `string` | Returns `{{ $node["<this node's name>"]<path> }}` for use inside another node's expression |

`opts.name` sets the node's display name (used by `$node["..."]` references and the dashboard canvas). If omitted, the node name defaults to the tool/flow type. Node names must be unique within a workflow — duplicates throw at build time.

`query`'s `type` parameter is typed to the **many-output tool set** only: `entity.query`, `event.query`, `web.search`, `calendar.list`, `calendar.freeBusy`, `airtable.listRecords`, `whatsapp.listTemplates`. Use `tool(...)` for every other tool.

### ManyHandle

A `ManyHandle` represents a `"many"` output stream (from `query`, `splitOut`, or a `forEach`'s `done` port). It exposes **only** `.forEach` and `.aggregate` — so a many output cannot be fed to an ordinary node. This is the [cardinality rule](/platform/workflows#cardinality) enforced at compile time.

```typescript
interface ManyHandle {
  forEach(
    opts: { batchSize?: number; throttleMs?: number; continueOnItemError?: boolean; name?: string },
    body: (item: OneHandle) => void,
  ): ManyHandle
  aggregate(opts: { mode: "collect" | "merge"; field?: string; name?: string }): OneHandle
}
```

| Method | Adds node | Returns | Notes |
|--------|-----------|---------|-------|
| `forEach` | `flow.foreach` | `ManyHandle` (the `done` port) | No `over` — iterates the incoming many stream directly |
| `aggregate` | `flow.aggregate` | `OneHandle` | Collapses the many items into one (`collect` → `{ items: [...] }`, `merge` → shallow merge / field pluck) |

> Note the two `forEach` shapes. `OneHandle.forEach` takes a single item and requires `over` (an expression pointing at an array field on that item). `ManyHandle.forEach` iterates an existing many stream and takes no `over`.

### ForEach loop variables

Inside a ForEach body, the current iteration item is `{{ $item.X }}`. The trigger item (`{{ $trigger.X }}`) and any dominating ancestor (`{{ $node["Name"].X }}`) remain available. See [Expressions](/platform/workflows#expressions).

```typescript
.forEach({ throttleMs: 1200 }, (lead) => {
  lead.tool("whatsapp.sendTemplate", {
    to: "{{ $item.data.phone }}",
    templateName: "friday_followup",
    language: "es",
    components: [],
  }, { name: "Send follow-up" })
})
```

`continueOnItemError` defaults to `true` — one failed item does not abort the loop. `throttleMs` (default `0`) delays each iteration, which keeps per-recipient sends under WhatsApp/Meta rate limits.

### Agent nodes with structured output

`agent(...)` with `output.schema` runs the agent with native structured output and emits the parsed object as its item's `json`. A Switch or IF then binds directly to a typed field — no free-text parsing.

```typescript
const classify = item.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: { 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" }),
  },
)
```

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 emits `{ text }` like a normal agent call.

### Referencing other nodes — `ref()`

`handle.ref(path?)` returns the expression string for that node's output:

```typescript
classify.ref()            // {{ $node["Classify VIP"] }}
classify.ref("json.isVip") // {{ $node["Classify VIP"].json.isVip }}
classify.ref(".json.isVip") // same — a leading dot is accepted
```

Because a handle only exists for a lexically-enclosing (dominating) ancestor, `ref()` is guaranteed to point at a node that runs on every path to the referencing node. Raw `"{{ $node[\"Name\"]... }}"` strings are also accepted and are validated by the same save-time [dominator check](/platform/workflows#node-references).

### build()

`build()` validates structure (exactly one trigger, unique node names) and returns the raw `WorkflowConfig`. Always export the result:

```typescript
export default wf.build()
```

## defineWorkflow (raw form)

`defineWorkflow(config)` accepts the flat `nodes[]` + `connections{}` form directly. It validates that there is exactly one trigger node, that `nodes` is non-empty, and that node names are unique. This is the compile target of the builder and the shape emitted by `workflows import-trigger`.

```typescript
export interface WorkflowNodeConfig {
  id: string
  name: string
  type: string
  params: Record<string, unknown>
}

export type WorkflowConnections = Record<
  string,
  { main: Array<Array<{ node: string; input: number }>> }
>

export interface WorkflowConfig {
  name: string
  slug: string
  description?: string
  nodes: WorkflowNodeConfig[]
  connections: WorkflowConnections
}

export function defineWorkflow(config: WorkflowConfig): WorkflowConfig
```

`connections[sourceId].main[outputPort]` is an array of `{ node, input }` targets. The outer index is the output port; multiple targets in one port is fan-out. Connections key on node `id` (not name) so renaming a node never breaks edges. Nodes carry **no** `position` — the dashboard auto-lays-out the graph.

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

export default defineWorkflow({
  name: "Friday Follow-up",
  slug: "friday-followup",
  nodes: [
    { id: "t1", name: "Every Friday", type: "trigger.cron", params: { cronSchedule: "0 9 * * 5", cronTimezone: "America/Santiago" } },
    { id: "q1", name: "Hot leads", type: "tool.entity.query", params: { type: "lead", filters: { stage: "hot" }, limit: 1000 } },
    { id: "fe1", name: "For each lead", type: "flow.foreach", params: { throttleMs: 1200, continueOnItemError: true } },
    { id: "wa1", name: "Send follow-up", type: "tool.whatsapp.sendTemplate", params: { to: "{{ $item.data.phone }}", templateName: "friday_followup", language: "es", components: [] } },
  ],
  connections: {
    t1: { main: [[{ node: "q1", input: 0 }]] },
    q1: { main: [[{ node: "fe1", input: 0 }]] },
    fe1: { main: [[{ node: "wa1", input: 0 }], []] },
  },
})
```

## Authoring rules (validated at sync)

The builder catches most mistakes at compile time, but the backend re-validates every workflow at sync. These rules **block** the sync if violated:

| Rule | Error condition |
|------|-----------------|
| Exactly one trigger | Zero or more than one trigger node |
| Unique node names / ids | Duplicate `name` or `id` |
| Acyclic | The graph contains a cycle |
| Tree (no merge) | A node has more than one incoming edge (`merge is a v2 feature`) |
| Trigger has no inputs | An edge targets the trigger node |
| Cardinality | A `"many"` output edge targets anything other than a `flow.foreach` (with no `over`) or `flow.aggregate` |
| Node references | A `$node["X"]` ref names a non-existent node or a node not on the referencing node's path |
| Transitions | `transitions` on a trigger whose `action` is not `"updated"` |
| Structured-output model | An `agent.chat` node with `output.schema` references an agent whose model cannot produce structured output |

See [Authoring rules](/platform/workflows#authoring-rules) for details.

## Full example — VIP payment flow (typed builder)

A payment transitioning `pending → confirmed` fans out to three 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()
```

`entity.query` returns a `ManyHandle`, so it can only be `.forEach`-ed (or `.aggregate`- d) — the cardinality rule is enforced by the type system. Each notify branch and the classify branch run independently; if the classify branch's agent fails, the two notifications still complete (see [per-branch failure isolation](/platform/workflows#execution-model)).

## Scaffolding and syncing

```bash
bunx struere add workflow vip-payment   # scaffolds workflows/vip-payment.ts
bunx struere dev                        # watches workflows/ and syncs on save
bunx struere sync                       # one-shot sync, then exit
```

`struere dev` watches the `workflows/` directory alongside `agents/`, `routers/`, and `triggers/`. See the [struere workflows](/cli/workflows) CLI page for inspecting, firing, and managing runs.
