# How workflows run

> The workflow engine's mental model, in depth. How an event picks the workflows that run, what a step is, how its output becomes /#steps.<id>, how transitions choose the next step, and what happens on timeouts, failures, not-found lookups, server errors, loops and callable workflows.

Source: https://developer.kustomer.com/docs/workflows/how-workflows-run

A workflow run is simple once you can picture it. An event happens. Kustomer finds the enabled workflows whose trigger matches that event. Each one starts a **run**: the event becomes the output of step `1`, and the run walks from step to step. Every step adds its output to a shared **context**, and its **transitions** decide which step comes next. When no transition matches, the run is over.

This page follows that path in detail, from the event to the last step, including what happens when things go wrong. For the parts of a workflow at a glance, see the [overview](https://developer.kustomer.com/docs/workflows). For the syntax used inside steps, see [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables).

```mermaid
flowchart TD
  E["Event, such as kustomer.conversation.update"] --> M["Each enabled workflow with that eventName,<br/>one at a time"]
  M --> T["Step 1 output = the trigger's output"]
  T --> C{"Check transitions<br/>in order"}
  C -->|"first match"| S["Run the target step<br/>and store its output at steps.&lt;id&gt;"]
  S -->|"then check its transitions"| C
  C -->|"no match"| N(["Run ends"])
```

## From event to run

### Which workflows run

Every workflow has one trigger. An event trigger names the event in `trigger.eventName`, such as `kustomer.conversation.update`. When that event happens, every **enabled** workflow with that event name gets a chance to run.

- **Several workflows can match one event.** They run one after another, not in parallel. Don't rely on the order, and don't build a workflow that depends on running before or after another one for the same event.
- **A workflow can stand in for another.** If an enabled workflow's [`replaces`](https://developer.kustomer.com/docs/workflows/workflow-json#override-an-app-workflow-with-replaces) points at a second workflow, the second one is skipped for that event.
- **Edits take about a minute to reach new runs.** Kustomer caches workflows, so a workflow you just created, enabled or saved may not match events for up to a minute.
- **Busy orgs can skip events.** Workflow events are rate limited per org and event name. Events over the limit are skipped, not queued. Calls to callable workflows aren't rate limited.

Runs for _different_ events are independent. They can run at the same time and finish in any order.

### The trigger is step 1

The trigger always has the ID `"1"`, and its output is the first thing in the run's context. What that output contains depends on the kind of trigger:

| Trigger                                                                            | Step `1` output                                                                                                       | Example reference                                               |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| A record event, such as `kustomer.conversation.update`                             | The record, **flattened**: attributes at the top level, relationships as plain IDs, and for updates, a `changes` diff | `/#steps.1.status`, `/#steps.1.changes.attributes.status.after` |
| An [inbound web hook](https://developer.kustomer.com/docs/events/inbound-webhooks) | The hook's payload, with your fields under `attributes.data`                                                          | `/#steps.1.attributes.data.email`                               |
| A [callable workflow](#callable-workflows-and-responses)                           | **Only** the caller's inputs, cast to the trigger's `schema`                                                          | `/#steps.1.conversationId`                                      |

The full shape of trigger output is in [Triggers and actions](https://developer.kustomer.com/docs/workflows/triggers-and-actions#what-the-trigger-outputs). Paths and how they resolve are covered in [Data and variables](https://developer.kustomer.com/docs/workflows/data-and-variables#what-you-can-address).

The trigger has transitions like any other step, and they act as the workflow's **trigger condition**. Most runs end right here: an update trigger fires on every change to the record, and the trigger's first condition filters out the changes you don't care about. A run that ends after the trigger is normal, not an error.

## Anatomy of a step

A step is one object in the workflow's `steps` array. Here's a real one, a customer lookup, both as the builder shows it and as JSON. Hover or tap each part, then press **Run** to watch it execute against three different API answers.

_A lookup step shown two ways. On the left, the workflow builder's card: badge 2 and the title Look up the customer, Action App Kustomer and Action Event Customer: Lookup (By ID), a required identifier field set to the trigger's Customer, a condition node named Customer is VIP leading to step 3, and a dashed note that the classic builder has no error-case editor. On the right, the same step as JSON with id lookup, action kustomer.customer.find, appVersion, params.identifier set to /#steps.1.customer, one transition to tag-vip when /#steps.lookup.custom.vipBool equals true, one error case that stops processing when /#error.status is 400, and meta.displayName. Selecting a part highlights it on both sides and explains it. Run plays the step: params resolve, the action runs, the output is stored as steps.lookup, and transitions are checked. With a 200 the VIP branch matches, with a 404 the output is empty and no transition matches so the run ends successfully, and with a 400 the step fails, /#error is set and the error case stops the run quietly._

| Key           | Required | What it does                                                                                                                                                        |
| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | Yes      | Names the step. Its output is stored as `steps.<id>`, and transitions use it as their `target`. Use readable IDs without dots when you write JSON, such as `lookup` |
| `action`      | No       | The action to run, such as `kustomer.customer.find`. A step without `action` is a **Condition Step**: it only evaluates its transitions                             |
| `params`      | No       | The action's inputs: literals, [references and templates](https://developer.kustomer.com/docs/workflows/data-and-variables)                                         |
| `transitions` | Yes      | Where to go next, checked in order. `[]` makes this a final step                                                                                                    |
| `errorCases`  | No       | What to do when the action fails                                                                                                                                    |
| `appVersion`  | No       | The app and version range of the action, such as `kustomer-^1.2.1`. The builder adds it when you save                                                               |
| `meta`        | No       | Labels for the builder. `displayName` is the step's title                                                                                                           |

The [Workflow JSON](https://developer.kustomer.com/docs/workflows/workflow-json#steps) reference has every key's limits and patterns.

> **Badge numbers aren't step IDs:** The builder numbers steps 1, 2, 3 by their position on the canvas. In the JSON, each step has a generated short ID, such as `SAk4K54Ly`, and references use that ID: `/#steps.SAk4K54Ly.id`. Hover a step's badge and choose **Copy Step ID** to get it. Only the trigger's ID is always `1`.

## What happens in each step

#### Resolve the params

Each param is resolved against everything the run has so far: earlier steps' outputs, workflow variables and app settings. A string that is exactly one reference, such as `"/#steps.1.customer"`, becomes that value with its type. Any other string is rendered as a template and becomes a string. Then each param is converted to the type the action's input expects.

#### Drop params that resolved to nothing

A reference that resolves to `null` or to nothing at all removes the param, so it isn't sent. An empty object is treated as not set, too. The one exception is a `null` queue, which is kept and means "unassign".

#### Run the action

The action runs with the resolved params. A Condition Step has no action, so it skips this stage.

#### Store the output

On success, the action's output is stored in the context as `steps.<id>`. Kustomer actions return the record, flattened in the same way as trigger output.

#### Check the transitions

The step's transitions are checked in order, and the first one whose condition passes picks the next step. If none pass, the run ends successfully.

> **Always supply required inputs:** A Kustomer action with a missing or empty required input may fail or return empty output. Empty values count as missing, including `0`, `false` and `""`. If a required param comes from a reference, guard the step with an `exists` condition on that value.

### Outputs become /#steps.\<id>

Once a step has run, any later step can read its output by the step's ID. Here's the lookup step from the scene above and a note step that uses its output:

```json title="Two steps: note reads the output of lookup"
{
  "steps": [
    {
      "id": "lookup",
      "action": "kustomer.customer.find",
      "params": { "identifier": "/#steps.1.customer" },
      "transitions": [
        {
          "target": "note",
          "condition": { "op": "exists", "values": ["/#steps.lookup.id"] },
          "meta": { "name": "Customer found" }
        }
      ]
    },
    {
      "id": "note",
      "action": "kustomer.note.create",
      "params": {
        "conversation": "/#steps.1.id",
        "body": "Customer: {{{steps.lookup.name}}}, {{{steps.lookup.emails.0.email}}}"
      },
      "transitions": []
    }
  ]
}
```

After `lookup` runs, the context holds its output under `steps.lookup`:

```json title="steps.lookup (abridged)"
{
  "id": "6512ef18a4b1e1001a6b2c11",
  "name": "Jane Rivera",
  "emails": [
    { "email": "jane@example.com" },
    { "email": "jane.rivera@example.com" }
  ],
  "custom": { "vipBool": true }
}
```

So `/#steps.lookup.id` is the customer's ID, `/#steps.lookup.emails` is the whole array, and the note's body renders as `Customer: Jane Rivera, jane@example.com`. A step can only read outputs of steps that already ran on this path. A reference to a step that didn't run, or to a field that isn't there, resolves to nothing rather than causing an error.

### How transitions choose the next step

After a step runs, the engine looks at its `transitions` from first to last:

1. It evaluates each transition's `condition` in order.
2. The **first** one that passes wins, and the run moves to its `target`. Later transitions aren't checked.
3. If **none** pass, the run ends. That's a successful run, not a failure.

That makes order meaningful. Put the most specific condition first and the broadest last. In the builder, sibling condition nodes are checked **left to right**, and you reorder them with **Move left** and **Move right**.

```json title="Most specific first, catch-all last"
{
  "id": "route",
  "transitions": [
    {
      "target": "urgent",
      "condition": {
        "op": "and",
        "values": [
          { "op": "gte", "values": ["/#steps.1.priority", 4] },
          { "op": "eq", "values": ["/#steps.lookup.custom.vipBool", true] }
        ]
      },
      "meta": { "name": "Urgent VIP" }
    },
    {
      "target": "vip",
      "condition": { "op": "eq", "values": ["/#steps.lookup.custom.vipBool", true] },
      "meta": { "name": "VIP" }
    },
    {
      "target": "everyone-else",
      "condition": { "op": "true", "values": [true] },
      "meta": { "name": "Always" }
    }
  ]
}
```

`route` has no `action`, so it's a Condition Step. A VIP conversation with priority 4 matches all three conditions, but only `urgent` runs. If `vip` came first, `urgent` would never run.

- `{ "op": "true", "values": [true] }` always passes. When it's a step's only transition, the builder draws a plain line instead of a condition node.
- A step can have up to 10 transitions, and no two can be identical.
- A `target` that doesn't match any step fails the run with `next step not found`. An operator the engine doesn't support fails it with `operation <op> not supported`.

The operators and their casting rules are in [Conditions and variables](https://developer.kustomer.com/docs/workflows/conditions-and-expressions#operators).

## The run context

Everything a step can read lives in one object, the run's **context**. References such as `/#steps.lookup.id` are paths into it.

```json title="The context partway through a run (abridged)"
{
  "steps": {
    "1": {
      "id": "6512f0c2a4b1e1001a6b2c90",
      "status": "done",
      "customer": "6512ef18a4b1e1001a6b2c11"
    },
    "lookup": {
      "id": "6512ef18a4b1e1001a6b2c11",
      "name": "Jane Rivera",
      "custom": { "vipBool": true }
    }
  },
  "variables": { "6513b2e4a4b1e1001a6b3f10": "tok_9f2c41" }
}
```

- `steps` grows by one entry per step that succeeds. A step that failed and was continued past by an error case adds nothing.
- `variables` holds the workflow's [variables](https://developer.kustomer.com/docs/workflows/data-and-variables#workflow-variables), keyed by ID. They're loaded only when the workflow refers to them.
- `error` appears only while a failed step's error cases are checked. See [Error cases](#error-cases).

When logging is on, Kustomer records each step's input, output, success and timing, plus the workflow version that ran. Secrets that Kustomer adds are removed from the logged input. Failed-run records store the same context with each output under `steps.<id>.output`. See [Debugging](https://developer.kustomer.com/docs/workflows/debugging).

## Time limits

A run has **5 minutes**. The limit is checked **between** steps, so a single slow step can run past it, and the run ends when that step finishes. A REST step waits up to 60 seconds for a response. For anything slower, reply quickly and finish the work asynchronously, as described in [Keep it fast](https://developer.kustomer.com/docs/workflows/code-steps#keep-it-fast).

## When a step fails

How a step fails decides what happens next. Kustomer actions follow the HTTP status of the API call they make:

| What happened                                                                    | Result                                                                                                      |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| A Kustomer action returns `2xx`                                                  | Success. The output is stored                                                                               |
| A Kustomer action returns `404`                                                  | **Success with empty output**                                                                               |
| A Kustomer action returns another `4xx`                                          | The step fails. The error is at `/#error`, and the step's error cases apply                                 |
| A Kustomer action returns `5xx`, or there's an unexpected system error           | Error cases are skipped. The run **starts again from step 1**. Nothing is recorded in the workflow's errors |
| A REST step gets any HTTP status                                                 | Success. Branch on `/#steps.<id>.response.statusCode`                                                       |
| A REST step gets no response (DNS failure, connection refused or reset, timeout) | The step fails                                                                                              |
| A bad template, a missing action or trigger, or an unsupported operator          | The run fails with that message                                                                             |
| The run passes 5 minutes                                                         | The run ends after the current step                                                                         |

```mermaid
flowchart TD
  A["Kustomer action responds"] --> B{"HTTP status"}
  B -->|"2xx"| OK["Success:<br/>output stored"]
  B -->|"404"| EMPTY["Success:<br/>empty output"]
  B -->|"other 4xx"| F["Step fails:<br/>the error is set"]
  B -->|"5xx"| R(["Error cases skipped:<br/>run restarts from step 1"])
  F --> EC{"errorCases,<br/>checked in order"}
  EC -->|"continue"| CONT["No output stored:<br/>step's transitions checked"]
  EC -->|"stop-processing"| STOP(["Run ends quietly"])
  EC -->|"no case matches"| FAIL(["Run fails and is<br/>recorded as an error"])
```

### Error cases

Error cases tell the engine what to do when a step fails. Each case has a `condition` and an `errorAction`. The cases are checked in order, and the first match applies. While they're checked, the error is available at `/#error`. For a Kustomer action, that's the first item of the API's `errors` array, with fields such as `status`, `code`, `title`, `detail` and `source`.

```json title="Continue past a validation error, stop quietly on anything else"
{
  "id": "create-customer",
  "action": "kustomer.customer.create",
  "params": {
    "name": "/#steps.1.attributes.data.name",
    "email": "/#steps.1.attributes.data.email"
  },
  "errorCases": [
    {
      "condition": {
        "op": "or",
        "values": [{ "op": "eq", "values": ["/#error.status", 400] }]
      },
      "errorAction": { "type": "continue" }
    },
    {
      "condition": { "op": "and", "values": [] },
      "errorAction": { "type": "stop-processing" }
    }
  ],
  "transitions": [
    {
      "target": "record-order",
      "condition": { "op": "exists", "values": ["/#steps.create-customer.id"] },
      "meta": { "name": "Customer created" }
    },
    {
      "target": "notify-team",
      "condition": { "op": "true", "values": [true] },
      "meta": { "name": "Not created" }
    }
  ]
}
```

| `errorAction.type` | Effect                                                                                                                       |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `continue`         | The step stores **no output**, and the run moves on to the step's transitions. Branch on whether the output exists, as above |
| `stop-processing`  | The run ends quietly. It isn't recorded as a failure                                                                         |
| `retry-workflow`   | Behaves exactly like `continue`. Nothing is retried                                                                          |
| `retry-action`     | Isn't handled, so the step fails as if no case matched                                                                       |

- A case whose `condition.values` is empty matches every error. Put it last.
- An error case's `condition.op` must be `and` or `or`. Each case can hold up to five conditions, and a step can have up to 10 cases.
- Read the error at `/#error.status`, `/#error.code` and so on. It's never at `/#steps.<id>.error`.
- If no case matches, the run stops and the failure is recorded on the workflow. See [Debugging](https://developer.kustomer.com/docs/workflows/debugging).

> **Add error cases in JSON:** The classic workflow builder has no editor for error cases. Add `errorCases` with **Edit workflow JSON** in the builder's options menu, or with [`PUT /v1/workflows/{id}`](https://developer.kustomer.com/api/workflows/update-a-workflow-based-on-the-id).

### Not found isn't a failure

A Kustomer action that gets a `404` succeeds with empty output. A lookup that finds nothing isn't an error, so error cases don't see it. Branch on the result instead:

```json title="Branch after every lookup"
{
  "id": "find",
  "action": "kustomer.customer.find-by-email",
  "params": { "email": "/#steps.1.attributes.data.email" },
  "transitions": [
    {
      "target": "update-customer",
      "condition": { "op": "exists", "values": ["/#steps.find.id"] },
      "meta": { "name": "Customer found" }
    },
    {
      "target": "create-customer",
      "condition": { "op": "dne", "values": ["/#steps.find.id"] },
      "meta": { "name": "No customer yet" }
    }
  ]
}
```

Without the second transition, a run that finds no customer simply ends.

### Server errors restart the run

When a Kustomer action gets a `5xx` response, or the platform hits an unexpected error, the step's error cases are skipped. Kustomer retries the run, and the retry **starts again from step 1**. Steps that already succeeded run again. These retries aren't recorded in the workflow's errors.

So design every step before a risky one to be **safe to repeat**:

- Prefer lookups and additive changes, such as `append-tags`, early in the workflow. Adding the same tag twice changes nothing.
- Before creating a record, look it up first, so a second run finds the record from the first run instead of creating a duplicate. Storing your system's ID as an external ID and using a `find-by-external-id` lookup is a reliable way to do this.
- Be careful with steps that have side effects outside Kustomer, such as REST calls. Use an idempotency key, such as the conversation ID, so your service can ignore a repeat.

[Make steps safe to repeat](https://developer.kustomer.com/docs/workflows/patterns#make-steps-safe-to-repeat) shows these techniques as complete workflows.

## Workflows can't loop

A transition can only move forward. When you save a workflow, Kustomer rejects any transition whose target is a step already on the path to it, with a `400` error:

```text
Step with ID <id> transitions to a previous step with ID <workflowId>:<target>
```

The builder shows the same problem on the canvas as "Cycle detected: _target_ already visited".

To repeat work later, such as checking a conversation again after an hour, have the workflow schedule a [callable workflow](#callable-workflows-and-responses) with the Scheduler app's **Schedule a Workflow** action. The callable workflow runs later with fresh data. See [Schedule follow-ups instead of looping](https://developer.kustomer.com/docs/workflows/patterns#schedule-follow-ups-instead-of-looping) for a worked example.

> **A workflow can still trigger itself:** The save-time check only covers transitions inside one workflow. A workflow that updates the record that triggered it fires its own trigger again. Guard update triggers with a condition on `changes`, as described in [Workflows](https://developer.kustomer.com/docs/workflows#the-life-of-a-run).

## Callable workflows and responses

A **callable** workflow has no event trigger. Its trigger declares the inputs it accepts, and something else starts it, usually the Scheduler app's **Schedule a Workflow** action in another workflow.

```mermaid
sequenceDiagram
  participant A as Workflow A
  participant S as Scheduler app
  participant B as Callable workflow B
  A->>S: Schedule a Workflow (workflowId, runAt, inputs)
  Note over S: Waits until runAt
  S->>B: Start a run with the inputs
  B->>B: Step 1 output = inputs, cast to trigger.schema
  B->>B: Re-fetch the record, then act
```

- **Step 1 is only the inputs.** The caller's `inputs` are cast to `trigger.schema` and become step `1`'s output. Nothing else is added, so read an input as `/#steps.1.conversationId`, never `/#steps.1.inputs.conversationId`.
- **Kustomer sets the event name.** Don't set `eventName` yourself. Kustomer sets it to `kustomer.workflow.<workflowId>.call`.
- **Time passes.** By the time a scheduled workflow runs, the record may have changed. Look it up again before you act on it.
- **Scheduling has its own checks.** `runAt.type` must be `absolute` (an ISO 8601 timestamp) or `relative` (whole seconds), or the step fails with "runAt type must be one of absolute or relative" or "invalid runAt value". Inputs that are too large fail with "Payload too large to schedule workflow".

A callable workflow can also send a result back with **Workflow: Send Response** (`kustomer.workflow.respond`), which takes `outputs`. That only matters when the caller waits for a result, for example an AI agent that uses the workflow as a tool. The caller receives:

| The run…                          | The caller gets                    |
| --------------------------------- | ---------------------------------- |
| Reaches a Send Response step      | Success, with the `outputs`        |
| Ends without a Send Response step | No response                        |
| Fails                             | A workflow error or a system error |

Declare the outputs on the builder's **Output Parameters** tab, which saves them as `response.schema`. A complete callable workflow, with its trigger schema and a respond step, is in [Callable and scheduled workflows](https://developer.kustomer.com/docs/workflows/triggers-and-actions#callable-and-scheduled-workflows).

## What's checked when you save

The API validates every workflow when you create or update it, so some mistakes never reach a run:

| Rule              | Detail                                                                                                |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| No loops          | A transition can't target a step already on its path                                                  |
| Transitions       | Up to 10 per step, and no two identical                                                               |
| Error cases       | Up to 10 per step, each with up to 5 conditions. `condition.op` must be `and` or `or`                 |
| Targets           | Can't contain dots                                                                                    |
| Steps             | Only `id` and `transitions` are required. `action` is optional                                        |
| Callable triggers | Need `id`, `callable`, `schema` and `transitions`                                                     |
| App settings      | An invalid `{{{ setting … }}}` reference is rejected with "Invalid app settings configuration passed" |
| Names             | Stored lowercase and trimmed, and unique in your org. A duplicate name returns `409`                  |
| App workflows     | Can't be edited (`409`), but you can still turn them on or off and toggle logging                     |

Everything else, such as a reference to a field that doesn't exist, only shows up at run time. Check the [logs](https://developer.kustomer.com/docs/workflows/debugging) after every change.

## Next
