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.
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.<id>"]
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
replacespoints 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:
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.
- Action App
- Kustomer
- Action Event
- Customer: Lookup (By ID)
idNames 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.
The Workflow JSON reference has every key's limits and patterns.
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
nullor 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 anullqueue, 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.
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:
{
"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:
{
"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:
- It evaluates each transition's
conditionin order. - The first one that passes wins, and the run moves to its
target. Later transitions aren't checked. - 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.
{
"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
targetthat doesn't match any step fails the run withnext step not found. An operator the engine doesn't support fails it withoperation <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.
{
"steps": {
"1": {
"id": "6512f0c2a4b1e1001a6b2c90",
"status": "done",
"customer": "6512ef18a4b1e1001a6b2c11"
},
"lookup": {
"id": "6512ef18a4b1e1001a6b2c11",
"name": "Jane Rivera",
"custom": { "vipBool": true }
}
},
"variables": { "6513b2e4a4b1e1001a6b3f10": "tok_9f2c41" }
}stepsgrows by one entry per step that succeeds. A step that failed and was continued past by an error case adds nothing.variablesholds the workflow's variables, keyed by ID. They're loaded only when the workflow refers to them.errorappears 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:
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.
{
"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" }
}
]
}- A case whose
condition.valuesis empty matches every error. Put it last. - An error case's
condition.opmust beandoror. Each case can hold up to five conditions, and a step can have up to 10 cases. - Read the error at
/#error.status,/#error.codeand 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:
{
"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-idlookup 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:
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.
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
inputsare cast totrigger.schemaand become step1'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
eventNameyourself. Kustomer sets it tokustomer.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.typemust beabsolute(an ISO 8601 timestamp) orrelative(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:
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:
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.