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.

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. For the syntax used inside steps, see Data and variables.

first match

then check its transitions

no match

Event, such as kustomer.conversation.update

Each enabled workflow with that eventName,
one at a time

Step 1 output = the trigger's output

Check transitions
in order

Run the target step
and store its output at steps.<id>

Run ends

Diagram source
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 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:

TriggerStep 1 outputExample reference
A record event, such as kustomer.conversation.updateThe 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 hookThe hook's payload, with your fields under attributes.data/#steps.1.attributes.data.email
A callable workflowOnly the caller's inputs, cast to the trigger's schema/#steps.1.conversationId

The full shape of trigger output is in Triggers and actions. Paths and how they resolve are covered in Data and variables.

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.

InteractiveAnatomy of a step
In the builder
1Conversation Created
2 Copy Step ID
Action
Look up the customer
Action Information
Action App
Kustomer
Action Event
Customer: Lookup (By ID)
Required fields 1
identifier1 Customer
Customer is VIP
3Add the VIP tag
Error cases
No editor in the classic builder. Add them in Edit workflow JSON.
In the JSON
{
"id": "lookup",
"action": "kustomer.customer.find",
"appVersion": "kustomer-^1.2.1",
"params": {
"identifier": "/#steps.1.customer"
},
"transitions": [
{
"target": "tag-vip",
"condition": {
"op": "eq",
"values": ["/#steps.lookup.custom.vipBool", true]
},
"meta": { "name": "Customer is VIP" }
}
],
"errorCases": [
{
"condition": {
"op": "or",
"values": [
{ "op": "eq", "values": ["/#error.status", 400] }
]
},
"errorAction": { "type": "stop-processing" }
}
],
"meta": { "displayName": "Look up the customer" }
}
Tour 1 of 6
id

Names the step. Its output is stored under this ID, so later steps read it as /#steps.lookup.<field>, and transitions target it. The badge 2 on the canvas is only the step’s position. Builder steps get random IDs such as SAk4K54Ly; hover the badge and choose Copy Step ID to get one.

A lookup step from a workflow triggered by Conversation Created. Hover, focus or tap any part of the card or the JSON. Run plays the step against three possible API answers. Context and error abridged.
KeyRequiredWhat it does
idYesNames 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
actionNoThe action to run, such as kustomer.customer.find. A step without action is a Condition Step: it only evaluates its transitions
paramsNoThe action's inputs: literals, references and templates
transitionsYesWhere to go next, checked in order. [] makes this a final step
errorCasesNoWhat to do when the action fails
appVersionNoThe app and version range of the action, such as kustomer-^1.2.1. The builder adds it when you save
metaNoLabels for the builder. displayName is the step's title

The Workflow JSON reference has every key's limits and patterns.

What happens in each step

  1. 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.

  2. 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".

  3. Run the action

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

  4. 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.

  5. 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.

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:

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:

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.

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.

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.

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, 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.

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.

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.

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 happenedResult
A Kustomer action returns 2xxSuccess. The output is stored
A Kustomer action returns 404Success with empty output
A Kustomer action returns another 4xxThe 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 errorError cases are skipped. The run starts again from step 1. Nothing is recorded in the workflow's errors
A REST step gets any HTTP statusSuccess. 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 operatorThe run fails with that message
The run passes 5 minutesThe run ends after the current step

2xx

404

other 4xx

5xx

continue

stop-processing

no case matches

Kustomer action responds

HTTP status

Success:
output stored

Success:
empty output

Step fails:
the error is set

Error cases skipped:
run restarts from step 1

errorCases,
checked in order

No output stored:
step's transitions checked

Run ends quietly

Run fails and is
recorded as an error

Diagram source
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.

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.typeEffect
continueThe step stores no output, and the run moves on to the step's transitions. Branch on whether the output exists, as above
stop-processingThe run ends quietly. It isn't recorded as a failure
retry-workflowBehaves exactly like continue. Nothing is retried
retry-actionIsn'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.

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:

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 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 with the Scheduler app's Schedule a Workflow action. The callable workflow runs later with fresh data. See Schedule follow-ups instead of looping for a worked example.

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.

Callable workflow BScheduler appWorkflow ACallable workflow BScheduler appWorkflow AWaits until runAtSchedule a Workflow (workflowId, runAt, inputs)Start a run with the inputsStep 1 output = inputs, cast to trigger.schemaRe-fetch the record, then act
Diagram source
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 stepSuccess, with the outputs
Ends without a Send Response stepNo response
FailsA 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.

What's checked when you save

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

RuleDetail
No loopsA transition can't target a step already on its path
TransitionsUp to 10 per step, and no two identical
Error casesUp to 10 per step, each with up to 5 conditions. condition.op must be and or or
TargetsCan't contain dots
StepsOnly id and transitions are required. action is optional
Callable triggersNeed id, callable, schema and transitions
App settingsAn invalid {{{ setting … }}} reference is rejected with "Invalid app settings configuration passed"
NamesStored lowercase and trimmed, and unique in your org. A duplicate name returns 409
App workflowsCan'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 after every change.

Next