Triggers & Control Flow
These node types control the shape of a workflow's DAG rather than calling an external service: pausing, branching, looping, fanning in, calling other workflows, asserting outputs, and shaping synchronous webhook responses.
timer
Pauses the workflow for a fixed duration. Backed by Temporal's durable timer (workflow.Sleep), so the pause survives worker restarts and deploys without consuming worker resources.
{
"id": "wait_a_bit",
"type": "timer",
"depends_on": ["fetch_data"],
"config": {
"duration": 30,
"unit": "minutes"
}
}
| Field | Type | Default | Description |
|---|---|---|---|
duration | number or expression | — | Length of the pause, in the unit given by unit. Supports {{...}} expressions. |
unit | string | "seconds" | One of seconds, minutes, hours, days. |
Output: { "slept_for": "30m0s", "resumed_at": "2026-06-11T09:00:00Z" } — slept_for is the resolved duration (Go duration string) and resumed_at is the UTC timestamp the workflow continued at.
delay
Pauses the workflow either for a relative duration or until a specific point in time. Like timer, it's intercepted at the workflow level and executed via a durable Temporal sleep.
{
"id": "wait_until_business_hours",
"type": "delay",
"depends_on": ["create_ticket"],
"config": {
"operation": "delay_until",
"until": "2026-06-12T09:00:00Z"
}
}
| Field | Type | Default | Description |
|---|---|---|---|
operation | string | "delay_for" | delay_for (relative duration) or delay_until (absolute timestamp). |
seconds / minutes / hours / days / months / years | number | 0 | Used with delay_for — components are summed to compute the total duration. |
until | string (expression) | — | Used with delay_until — an ISO-8601/RFC3339 timestamp (or expression resolving to one) to sleep until. |
format | string | — | Optional input format hint for parsing until if it isn't already RFC3339. |
Output (delay_for): { "delayed_for_seconds": 1800, "operation": "delay_for" }
Output (delay_until): { "delayed_until": "2026-06-12T09:00:00Z", "delayed_seconds": 12345, "operation": "delay_until" }
loop
Iterates over an array and produces a transformed array as output. Each element is bound to a per-iteration variable that other expressions in transform can reference.
{
"id": "format_leads",
"type": "loop",
"depends_on": ["fetch_leads"],
"config": {
"items": "{{steps.fetch_leads.output.body.records}}",
"item_var": "lead",
"transform": {
"email": "{{lead.email}}",
"name": "{{lead.fields.Name}}"
}
}
}
| Field | Type | Default | Description |
|---|---|---|---|
items | array (expression) | — | Required. Must resolve to a JSON array. |
item_var | string | "item" | Name used to reference the current element inside transform expressions, e.g. {{item.field}}. |
transform | object (optional) | — | A map of output_key -> expression. If set, each element of items is mapped through this object (with item_var bound to that element) to produce the corresponding entry in results. If omitted, each item in items is passed through unchanged. |
Output: { "results": [...], "count": <number> } — results is the array of transformed (or pass-through) items, one per input element; count is its length.
loop performs a single in-place mapping over the array — it does not execute a nested sub-DAG of steps per item. To run a multi-step sub-workflow once per item, pair loop/batch with a subworkflow step (calling the sub-workflow once per element via your own fan-out, or once per batch).
batch
Splits an array into fixed-size chunks — useful for rate-limited APIs or for feeding a fixed number of items at a time into a downstream subworkflow or connector call.
{
"id": "chunk_records",
"type": "batch",
"depends_on": ["fetch_records"],
"config": {
"items": "{{steps.fetch_records.output.rows}}",
"size": 25
}
}
| Field | Type | Default | Description |
|---|---|---|---|
items | array (expression) | — | Required. Must resolve to a JSON array. |
size | number | 10 | Maximum number of elements per batch. Values <= 0 fall back to the default. |
Output: { "batches": [[...], [...], ...], "total": <number>, "batch_count": <number> } — batches is an array of arrays (each up to size elements), total is the input array length, and batch_count is the number of batches produced.
condition
Evaluates a single comparison and routes downstream steps along a true or false branch via branch_deps.
{
"id": "is_high_value",
"type": "condition",
"depends_on": ["fetch_lead"],
"config": {
"left": "{{steps.fetch_lead.output.deal_value}}",
"operator": "gt",
"right": 10000
}
}
| Field | Type | Default | Description |
|---|---|---|---|
left | any (expression) | — | Left-hand operand. Resolved and stringified for comparison. |
operator | string | "eq" | One of eq, ne, gt, lt, gte, lte, contains, starts_with, ends_with, is_empty, is_not_empty. gt/lt/gte/lte require numeric operands. |
right | any (expression) | — | Right-hand operand. Resolved and stringified for comparison. |
Output: { "result": true | false, "branch": "true" | "false" }
Downstream steps gate on this result with branch_deps:
{
"id": "notify_slack",
"type": "slack-send",
"depends_on": ["is_high_value"],
"branch_deps": [{ "step_id": "is_high_value", "branch": "true" }],
"config": { "...": "..." }
}
A step only runs once all of its branch_deps entries match the branch produced by the referenced condition/switch step. If the upstream step took a different branch, the step (and anything that depends only on it) is skipped.
switch
Routes to one of several named branches based on matching a value against a list of cases — a multi-way alternative to condition.
{
"id": "route_by_type",
"type": "switch",
"depends_on": ["fetch_event"],
"config": {
"value": "{{steps.fetch_event.output.body.type}}",
"cases": [
{ "match": "issue.opened", "label": "new_issue" },
{ "match": "issue.closed", "label": "closed_issue" }
]
}
}
| Field | Type | Default | Description |
|---|---|---|---|
value | any (expression) | — | The value to match. Resolved and stringified before comparison. |
cases | array of { match, label } | [] | Each case's match is stringified and compared against value. The first match wins, and its label becomes the produced branch. |
Output (match found): { "matched": true, "branch": "<label>", "value": "<resolved value>" }
Output (no match): { "matched": false, "branch": "default", "value": "<resolved value>" }
Downstream steps use branch_deps exactly as with condition, matching against the label values from cases (or "default" if nothing matched):
{
"branch_deps": [{ "step_id": "route_by_type", "branch": "new_issue" }]
}
subworkflow
Calls another workflow definition as a child Temporal workflow, waits for it to complete, and returns its output — used to compose smaller workflows into larger ones.
{
"id": "run_enrichment",
"type": "subworkflow",
"depends_on": ["fetch_lead"],
"config": {
"workflow_id": "8f1b2c3d-...-enrichment-wf",
"input_data": {
"email": "{{steps.fetch_lead.output.email}}"
},
"timeout_min": 20
}
}
| Field | Type | Default | Description |
|---|---|---|---|
workflow_id | string (expression) | — | Required. UUID of the target workflow definition. The workflow must be status: active. |
input_data | object or string (expression) | — | Optional. If set, this becomes the child workflow's trigger body ({{trigger.body...}} inside the child). If it's a JSON string it is parsed; if parsing fails it's wrapped as {"raw": "<string>"}. If omitted, the child receives the entire parent execution context (steps, vars, trigger, input) as its trigger data. |
timeout_min | number | 20 | Maximum time (minutes) to wait for the child workflow to complete. |
Output: { "output": { ... }, "execution_id": "<uuid>", "status": "completed" } — output is the child workflow's final output map (reference nested fields as {{steps.run_enrichment.output.output.<field>}}), and execution_id is the child's own execution ID (visible in Executions & Monitoring).
If the child workflow fails, the step fails (it is not auto-retried — RetryPolicy: MaximumAttempts: 1). Set continue_on: "error" on the step to let the parent continue with {"error": "..."} as the step output.
merge
Flattens the outputs of several upstream steps into a single map — useful for fan-in after parallel branches, or to gather scattered fields before a single downstream call.
{
"id": "combine_results",
"type": "merge",
"depends_on": ["fetch_user", "fetch_orders"],
"config": {
"steps": ["fetch_user", "fetch_orders"]
}
}
| Field | Type | Default | Description |
|---|---|---|---|
steps | array of strings | [] | Step IDs whose output maps should be merged. Each must already be present in depends_on. |
Output: A single flat map containing every key from each listed step's output map, in list order — later steps' keys overwrite earlier ones if there's a name collision. Steps whose output isn't an object are skipped.
test-runner
Runs a list of assertions against prior step outputs. Typically placed at the end of a workflow (or sub-workflow) to validate results — useful for CI-style regression checks on workflow changes.
{
"id": "verify_output",
"type": "test-runner",
"depends_on": ["call_llm"],
"config": {
"label": "LLM Output Checks",
"stop_on_fail": true,
"assertions": [
{ "name": "has_summary", "path": "{{steps.call_llm.output.text}}", "op": "not_empty" },
{ "name": "status_ok", "path": "{{steps.call_llm.output.status}}", "op": "status_ok" }
]
}
}
| Field | Type | Default | Description |
|---|---|---|---|
label | string | "Test Suite" | Label included in the summary output. |
stop_on_fail | boolean | true | If true, the step (and workflow, unless continue_on: "error" is set) fails when any assertion fails. If false, results are recorded but the step still succeeds. |
assertions | array of objects | [] | Each item: { "name": "...", "path": "<expression>", "op": "<operator>", "expected": <any> }. name defaults to assertion_N; op defaults to not_empty. |
Supported op values: not_empty, equals/eq, not_equals/neq, contains, not_contains, starts_with, ends_with, regex, json_valid, type_is (compares against string, number, bool, slice, map, etc.), lt, gt, lte, gte, status_ok (HTTP status is 2xx).
Output:
{
"label": "LLM Output Checks",
"passed": 2,
"failed": 0,
"total": 2,
"results": [
{ "name": "has_summary", "op": "not_empty", "actual": "...", "expected": null, "passed": true }
],
"summary": "LLM Output Checks: 2/2 passed",
"ok": true
}
When stop_on_fail is true and one or more assertions fail, the step returns this same output map alongside an error listing each failed assertion's name and message.
webhook_response
Sends a custom HTTP response back to the caller of a synchronous webhook (POST /webhooks/{workflowId}/sync), letting you control the status code, headers, and body of the workflow's HTTP reply instead of returning the raw final step output.
{
"id": "respond",
"type": "webhook_response",
"depends_on": ["build_result"],
"config": {
"status_code": 200,
"content_type": "json",
"body": {
"ok": true,
"result": "{{steps.build_result.output.summary}}"
},
"headers": {
"X-Request-Id": "{{trigger.headers.x-request-id}}"
}
}
}
| Field | Type | Default | Description |
|---|---|---|---|
status_code | number | 200 | HTTP status code returned to the caller. |
content_type | string | "json" | One of json, text, html. Sets the Content-Type response header (application/json, text/plain, text/html) unless overridden in headers. |
body | any (expression) | — | The response body. For json, the resolved value is encoded as JSON directly; for text/html, it's stringified. |
headers | object | {} | Additional response headers, each value resolved as an expression. |
Output: { "sent": true | false, "status_code": <number> } — sent is false if no caller is currently waiting on this execution (e.g. the workflow wasn't invoked via /sync, or the response was already sent by an earlier step).
A workflow can include at most one effective webhook_response per execution path — only the first one reached delivers the reply; subsequent calls return sent: false.