Skip to main content
The workflow builder turns a chain of method calls into a static, serializable workflow graph. createStep() declares a code step; createWorkflow() returns a builder whose chain methods add steps and containers and whose .commit() validates the graph and returns a LuaWorkflow. Predicates and data bindings are built with the helpers on this page, never with closures: a function where a predicate, template or mapping is expected fails the build. Workflows live anywhere under src/ and may be listed on LuaAgent under workflows. Starting and steering runs from code is Workflows; writing a workflow end to end is the authoring guide. Verified against lua-cli 3.33.0.

Quick example

Two code steps, an agent step, and a conditional agent step placed inside the switch by its id:
src/workflows/ticket-triage.ts

Workflow definition

createWorkflow()

Returns a LuaWorkflowBuilder for the given config. Validation of the config happens here; validation of the graph happens in .commit().
string
required
Server identifier, and what lua workflows and Workflows.start() address. Must match ^[a-z][a-z0-9-_]*$.
string
Shown in lua workflows list.
ZodType
required
Validated on every start; a failing input is refused.
ZodType
Validates the run output.
ZodType
Types ctx.state. The run-scoped store holds at most 64 KB.
{ maxCredits?: number; maxSteps?: number; maxDurationSeconds?: number }
Per-run defaults. maxCredits counts agent steps, not tokens: an inline agent step settles 1 credit, a tier: 'job' attempt 4. maxDurationSeconds defaults to 604 800 (7 days), or 2 592 000 (30 days) when the graph contains an approval, a signal wait, or a step with suspendSchema. A run that exhausts a dimension parks on a budget gate; raise it with lua workflows raise-budget.
'allow' | 'forbid'
'forbid' refuses another start while a run of this workflow is in flight (RUNS_IN_FLIGHT, with the blocking run id).
{ roles: string[]; users?: string[]; ownerBypass?: boolean }
Who may read run outputs. roles must be non-empty; at most 20 roles and 50 user ids.
JobSchedule & { runAs?: 'installer' | 'system' }
The same { type: 'cron' | 'interval' | 'once', … } shape as a LuaJob. runAs only affects the copy an agent template installs: 'installer' (default) runs as the person who installed, 'system' runs with no person attached.
Record<string, unknown>
Literal run input for every scheduled fire. Required when inputSchema has required keys (schedule-input-required, a push blocker) and validated against it (schedule-input-invalid).
{ maxOccurrences?: number }
Missed occurrences to replay when a paused schedule is re-enabled; integer 1–200.
WorkflowGoalEnvelope
{ objective, judge: { agentId, role?, schema }, cadence: JobSchedule[], maxRuns, budget?, maxTotalCredits?, initialState? }. See goals and schedules.
WorkspaceSpec
A volume for Job-tier steps: { kind: 'git', repo, ref?, credentialsRef?, sizeGb?, ttlHours?, verify?, keepArtefacts?, backend? } or { kind: 'empty', sizeGb?, ttlHours?, keepArtefacts?, backend? }. repo and ref accept env.template(). See Job tier.
Array<{ key: string; integrationType: string; required?: boolean; description?: string }>
Connection keys the workflow acts through, resolved against the agent that runs it. key matches ^[a-z][a-z0-9_-]{0,63}$ and is what workspace.credentialsRef and a step’s requiredConnections name. See connections.
form is set by the compiler; never write it. Errorsinvalid-workflow-name; invalid-envelope for an empty outputVisibility.roles or a backfillOnEnable.maxOccurrences outside 1–200; cap-exceeded for more than 20 roles or 50 users; env-template-secret-key when schedule or workspace carries env.template() on a key ending in SECRET, TOKEN, KEY or PASSWORD.

defineWorkflow()

Sugar for createWorkflow followed by a build callback that must end in .commit().
Errorsinvalid-envelope when the callback returns anything other than the committed LuaWorkflow.

Code steps

createStep()

Declares a code step and returns it unchanged, typed by its schemas. The object is placed in a chain with .then(step) or as a container arm.
src/workflows/steps/sendEmails.ts
string
required
Unique within the workflow; ^[a-z][a-zA-Z0-9_-]{0,63}$. The characters [, #, . and : are reserved.
string
Shown in run listings.
ZodType
required
The step input after bindings are applied.
ZodType
required
The engine validates the returned output; a mismatch fails the attempt.
(ctx: WorkflowStepContext) => Promise<output>
required
The step body. After a resume it runs again from the top with ctx.resumeData set.
number
default:300
Integer ≥ 1. Worker tier allows up to 600; tier: 'job' allows up to 14 400 and defaults to 3600. An attempt that runs over is failed with TIMEOUT and counts toward retry.maxAttempts.
RetryPolicy
default:"{ maxAttempts: 1 }"
{ maxAttempts: 1–20; backoffSeconds?: number; backoff?: 'fixed' | 'exponential'; maxBackoffSeconds?: number }. Backoff is an engine-side wait, not a sleep in your code; 'exponential' doubles backoffSeconds per attempt up to maxBackoffSeconds (default 3600), which is only accepted with 'exponential'. A timed-out attempt is retried under this policy like a thrown error.
'none' | 'external'
default:"'none'"
'external' parks the step for a person instead of retrying it when a platform fault interrupts it.
'fail' | 'continue' | 'park'
default:"'fail'"
What the final failure does to the run: fail it, continue with null as this step’s output, or park the run on an exception gate for a person to retry, skip, complete or fail the step.
string[]
Declared connection keys or connection ids the step needs.
ZodType
Types the payload passed to ctx.suspend().
ZodType
Types ctx.resumeData, the data a person supplies to resume the step.
number
default:168
Deadline for a ctx.suspend() park; at most 720.
WorkflowBusinessHours
{ tz, calendar?: 'mon-fri' | { days, start, end, holidays? } }. Deadlines count business time.
'fail' | 'cancel-run'
What a missed resumeTimeoutHours does.
'job'
Run the step as a Job-tier pod. Implied by workspace.
{ mount: 'rw' | 'ro'; isolation?: 'shared' | 'worktree' }
Mount the workflow’s workspace. Requires tier: 'job' or no tier.
'small' | 'medium' | 'large'
Job-tier pod size; default 'small'.
WorkflowJobToolId[]
'shell' | 'read' | 'write' | 'edit' | 'glob' | 'grep' | 'git' | 'gh' | 'fetch' | 'ripwire'. Job tier only.
Errorsinvalid-step when the argument is not an object or has no execute; invalid-step-id when id is outside the grammar. When the step is placed in a chain: duplicate-step-id for a second object with the same id; workspace-requires-job-tier; cap-exceeded for jobTools without the Job tier or retry.maxAttempts over 20; invalid-envelope for a missing, non-integer or sub-1 retry.maxAttempts; backoff-invalid; timeout-out-of-range; timeout-exceeds-tier over 600 s on the worker tier; job-timeout-exceeds-cap over 14 400 s.

Step context

execute receives one object.

WorkflowStepResultError

Thrown by ctx.getStepResult(). Check err.code, not instanceof: a step runs in its own realm.
'STEP_RESULT_NOT_ANCESTOR' | 'STEP_RESULT_TOO_LARGE' | 'STEP_RESULT_OFFLOADED'
Not an upstream step; the output could not travel with the step (4 MiB budget per step); or the output is stored offloaded (over 256 KB) and could not be hydrated. For the last two, bind the value through the step’s input or read it through artefacts.
string
The requested step.
number
The output’s serialized size, for the size codes.
string
Why hydration failed, for STEP_RESULT_OFFLOADED.

Chain methods

Every method returns the builder. A StepRef is a createStep object or a string naming an entry declared elsewhere in the chain; a ContainerArm is a StepRef or a [mapConfig, stepRef] pair whose step runs with the map as its input.

then()

Appends a code step, or places a declared entry sequentially by id.

parallel()

Runs 2 to 16 arms concurrently. The output is { [stepId]: output }. An arm may be a [mapConfig, stepRef] pair, or the id of an approval or waitForSignal, which then parks beside its siblings.
merge applies to Job-tier worktree arms. Errorscap-exceeded outside 2–16 arms; container-arm-empty for a pair without a step; mapping-placement for a map arm.

switch()

Runs the first arm whose predicate is true; otherwise runs when none is. Without otherwise, a run with no true arm continues past the switch. An arm target may be a map placed by id. When the switch is the last entry, the taken arm’s output is the run output.
Errorsinvalid-envelope with no arms; closure-predicate for a function in the predicate slot.

branch()

Runs every arm whose predicate is true. exclusive: true behaves like switch without otherwise.

foreach()

Runs the body once per item. The body receives each item as its input; the output is an array. An approval or waitForSignal body means one approval or wait per item.
TypedRef<unknown[]> | MapDescriptor | { initData: true; path?: string }
Where the items come from: step(x).path('list'), init('rows'), fromInit('rows'), { initData: true, path: 'rows' }, or a single-step fromStep(x, 'list'). Omitted, the body iterates the previous entry’s output. value, template, fromRequest, rows, fromKnowledge and a fan-in fromStep([…]) are refused (invalid-envelope); put a .map({ '': … }, { id }) before the foreach instead.
number
default:4
1–16.
number
default:256
1–20 000. A longer list fails the run; it is never truncated.
{ size: number }
Process items in chunks; size is an integer from 1 to maxItems. A chunked body cannot be an approval or signal wait.
{ perSecond?: number } | { perMinute?: number }
Exactly one of the two; perSecond at most 50, perMinute at most 3000.
Errorscap-exceeded, chunk-size-invalid, rate-limit-invalid, invalid-envelope; mapping-placement for a [map, step] body.

dowhile() and dountil()

Repeats the body while (or until) the predicate holds. The body receives the previous iteration’s output. A loop body cannot be an approval, a signal wait, or a [map, step] pair.
number
default:100
Iteration cap.
number
Engine-side wait between iterations; integer 1–86 400.
Errorsloop-interval-out-of-range; node-type-unsupported-in-container; mapping-placement.

map()

Reshapes data at the top level of the chain. Each key is a mapping descriptor or a literal; the single key '' makes the output the descriptor’s value itself rather than an object.
The default id is map_<n>. Once a workflow has two or more maps every map needs an explicit id: .commit() warns map-id-required and lua push refuses. A map can be a switch arm or otherwise by id; it can never be a parallel, foreach or loop arm. Errorsclosure-binding for a function in a descriptor slot.

sleep()

An engine-side wait of ms milliseconds; a literal, non-negative number. The default id is sleep_<n>.

sleepUntil()

Builds a wait until an ISO timestamp or a template() that resolves to one. The engine does not execute this step in 3.33.0: lua compile and lua push refuse it with node-type-unsupported-by-engine. Use sleep().

agentStep()

Declares an agent turn. The step’s output carries the reply as text; with outputSchema, the reply is validated and a mismatch fails the attempt.
string | EnvRefBinding
required
A member agent id, '$self' for the owning agent, or env.template('KEY').
string | TemplateBinding | EnvRefBinding
required
A literal, template('…'), or env.template('KEY').
ZodType
Validates the reply.
string
Model code for this turn, overriding the agent’s.
{ connectionIds?: string[]; skillIds?: string[]; toolIds?: string[]; jobTools?: WorkflowJobToolId[] }
Tools offered on this turn; {} means none. jobTools needs tier: 'job'.
string
A static persona override for the turn.
number
Worker tier up to 600 (default 600 for an agent step); Job tier up to 86 400, and over 14 400 only with a workspace.
RetryPolicy
As for code steps.
'fail' | 'continue' | 'park'
As for code steps.
string[]
As for code steps.
'job'
Run the turn in a Job-tier pod. Implied by workspace.
{ mount: 'rw' | 'ro'; isolation?: 'shared' | 'worktree' }
Mount the workflow’s workspace.
'small' | 'medium' | 'large'
Pod size.
'claude-code' | 'generic'
Coding harness; Job tier only.
number
Coding turns per harness query; integer 1–500. Job tier only.
number
Harness messages per attempt; integer 1–5000, default 400. Job tier only.
number
Input tokens per attempt; integer 1 000 000–500 000 000, default 4 000 000. Job tier only.
Errorsinvalid-envelope without agentId; workspace-requires-job-tier; harness-requires-job-tier; max-turns-requires-job-tier; max-turns-invalid; cap-exceeded for toolScope.jobTools off the Job tier; timeout-exceeds-tier; job-timeout-exceeds-cap; long-job-requires-workspace; the retry errors; invalid-step-id.

specialistStep()

Declares a turn of the owning agent under an additive role: a reviewer, verifier or planner without a second agent. Ephemeral roles must be enabled for the organization or agent; otherwise lua push refuses with ephemeral-specialists-disabled.
{ name: string; instructions: string; tools: string[] } | { ref: string }
required
An inline role (instructions at most 4000 characters, tools at most 64 names from the agent’s own tools; [] means none) or a reference to a role in the organization’s library (role-ref-unknown at push when it does not exist).
string | TemplateBinding | EnvRefBinding
required
As for agentStep.
ZodType
As for agentStep.
string
As for agentStep.
{ connectionIds?; skillIds?; toolIds? }
As for agentStep.
As for code steps; worker tier only.
Errorsinvalid-envelope without a role or with a non-array tools; role-ref-and-inline when ref is combined with inline fields; ephemeral-role-too-long; cap-exceeded over 64 tools.

toolStep()

Calls a LuaTool directly, with an input built from mapping descriptors.
LuaMapConfig
The tool input; each key a descriptor or literal.
As for code steps; worker tier only.
Errorsinvalid-envelope when tool is not a LuaTool value; closure-binding for a function inside input.

approval()

Declares a human decision. The approver sees the previous step’s output as the payload; the approval’s own output is what the next step reads.
src/workflows/refund.ts
string
required
Plain text shown on the approval card.
TemplateBinding
template('…') rendered on the card. A function is closure-binding.
WorkflowApproverSpec
default:"'creator'"
'creator', 'org-admins', { users: string[] }, { role }, { group }, or { governance: { policyId } }. users, role and group also accept a template() binding.
boolean
The person who started the run can never approve it. Not allowed with approver: 'creator'.
number | TemplateBinding
default:168
1–720; a binding is resolved when the step parks.
WorkflowSuspendTimeoutChain
default:"'deny'"
'deny', 'cancel-run', 'fail', 'continue', or an array of at most 3 { escalateTo, timeoutHours } hops ending in one of those.
'continue' | 'fail'
default:"'continue'"
By default a denial is data the next step reads.
WorkflowBusinessHours
Deadlines count business time.
boolean
Let the approver change the payload. Inferred true from a non-empty editablePaths; an explicit false beside paths is refused.
string[]
Paths the approver may edit: drafts, drafts[*], drafts[*].body, drafts[3].body, summary.title.
ZodType
Validates the edited payload. Requires editable.
{ edit: WorkflowApproverSpec; approve: WorkflowApproverSpec }
Whoever edits cannot be the one who approves. Requires editable.
Per-item approvals over an array in the payload. itemApprover may be { fromItem: '<field>' }; itemTimeout is { timeoutHours }.
Output — read by the next step; the payload the approver saw is not echoed back, so read it with getInitData() or getStepResult(), or project both into one input with a .map() before the step. Give that step’s inputSchema a .passthrough().
boolean
false on a denial and on a timeout under 'deny'.
'approved' | 'denied' | 'timed_out'
The decision word.
string
The approver’s note, else the decision word.
string
The note verbatim, when one was left (at most 2000 characters).
unknown
The payload as edited, when it was edited.
number
How many times the payload was edited before the decision.
{ id?: string; kind?: string; … }
The deciding principal.
Set when the deadline chain decided, how many hops fired, the evidence artifact ids, and the per-item rows.
Errorsinvalid-envelope without title; approver-excludes-only-candidate; four-eyes-requires-editable; editable-path-invalid; escalation-chain-too-long; escalation-chain-not-terminal; closure-binding.

waitForSignal()

Declares a wait for a named signal delivered from outside the run with Workflows.signal, the CLI, or the REST API. A signal that arrives before the wait begins is parked and consumed when it does.
src/workflows/pr-review.ts
string
required
The signal name a sender must use. The builder only requires a non-empty string; the platform accepts a delivery only when the name is 1–64 characters of [a-zA-Z0-9_.-], so a name outside that grammar can never be satisfied.
ZodType
Validates the payload; a mismatch is rejected at delivery.
number | TemplateBinding
Deadline for the wait. 1–720, checked when the step parks and on each escalation hop, not at build.
'fail' | 'continue'
default:"'fail'"
'continue' completes the step with { received: false, timedOut: true }.
WorkflowBusinessHours
Deadlines count business time.
Array<'webhook' | 'api' | 'user' | 'agent'>
default:"['webhook', 'api', 'user']"
Which callers may deliver it.
Output{ payload, source, signalId, receivedAt } (receivedAt in epoch milliseconds), or { received: false, timedOut: true } after a timeout under 'continue'. Errorsinvalid-envelope without signal.
Local runs only. Under lua test workflow, --signal completes the wait with the raw payload instead of the { payload, source, signalId, receivedAt } envelope. Read inputData.payload in the next step and expect it to be undefined offline.

workflow()

Declares a child run of another workflow, by LuaWorkflow value or by name on the same agent. The child runs with trigger: 'workflow'; nesting is at most 3 deep.
src/workflows/ticket-to-pr.ts
LuaMapConfig
The child run’s input, built from descriptors.
'inherit'
Mount the parent’s workspace in the child. The parent must declare one and the child must not.
RetryPolicy
default:"{ maxAttempts: 1 }"
Re-arms the row with a fresh child run when the child ends failed or timed_out on its own.
Errorsinvalid-envelope without a workflow or name; workspace-inherit-without-parent-workspace; workspace-inherit-conflict; closure-binding for a function inside input.

commit()

Resolves placements, validates the graph, and returns the LuaWorkflow. The compiler observes every commit, so a workflow that is never committed is not compiled.
Errorsempty-graph when nothing was added; unknown-step-ref, duplicate-step-id, mapping-placement, container-arm-empty, node-type-unsupported-in-container from placement; invalid-envelope on a second commit(). Warnings (read with getBuildWarnings(), printed by lua compile) — map-id-required, hitl-duration-defaulted, schedule-input-required, schedule-input-invalid.

Placement rule

Every chain call appends one entry where it is called; nothing is moved afterwards. agentStep, specialistStep, toolStep, map(…, { id }), workflow, approval and waitForSignal are declarations: a container (parallel, switch, branch, foreach, a loop) that names the id as a string claims the declaration and places it inside itself, whether the declaration comes before or after the container in the chain. A declaration no container claims is placed where it is called, and .then('<id>') places one sequentially. A createStep object declares no id, so a string arm cannot name it; pass the object.
src/workflows/brief.ts
Here the two angle steps run inside the parallel, the map runs inside the switch arm, and lowConfidence is the switch’s otherwise; none of them appears a second time at the top level. The same id placed by two containers is duplicate-step-id; a string that names nothing is unknown-step-ref. An approval or signal wait may be claimed by parallel, switch, branch or foreach, never by a loop, a [map, step] pair, or a chunked foreach (node-type-unsupported-in-container).

References and predicates

References name a value in the run; predicates compare references and literals. Both are plain data, so a function in their place fails the build.
src/workflows/predicates.ts
A predicate that reads a step which is not upstream of the container resolves to an unresolved binding at run time and fails the step before dispatch.

Mapping descriptors

Descriptors bind step inputs, map() keys, tool-step inputs and child-run inputs to values in the run. A member that is not a descriptor is passed through as the literal it is.
src/workflows/mapping-demo.ts

Template placeholders

template('…') strings are rendered by the engine when the step is dispatched. Write them in single quotes so TypeScript never interpolates them. Objects and arrays render as JSON; null and a missing leaf render as an empty string. An unknown namespace (${inputData.*} is not one), a step that is not upstream, or a path through a non-object fails the step with binding_unresolved before it is dispatched. In an agent prompt each rendered value is wrapped in a data fence marked untrusted, so upstream output reaches the model as data rather than instructions.

Environment templates

env.template('KEY') returns an EnvRefBinding placeholder and is accepted for agentId, prompts, workspace.repo and ref, schedule fields and mapping values. lua push workflow resolves every key from the target environment; a missing key aborts the push. Keys ending in SECRET, TOKEN, KEY or PASSWORD are refused with env-template-secret-key; read those with env('KEY') inside execute. getEnvTemplateKeys() lists the keys a workflow carries.

LuaWorkflow

The value .commit() returns. Never construct it yourself.

Build errors

createWorkflow, createStep, the chain methods and .commit() throw LuaWorkflowBuildError, a subclass of Error with name: 'LuaWorkflowBuildError'.
LuaWorkflowBuildCode
One of the codes in the following table.
string
The message, with the hint appended after an em dash when one exists.
string
What to change, when the builder knows.
workspace-not-declared and approval-inside-container remain in the LuaWorkflowBuildCode union but are not thrown by the builder. lua compile and lua push run a second validation over the serialized graph and can refuse what the builder accepted: node-type-unsupported-by-engine for sleepUntil, template-reference-unresolved for a ${stepResults.<id>…} placeholder that names a step which is not upstream, connection-key-undeclared and connection-declaration-invalid for connection keys, ephemeral-specialists-disabled and role-ref-unknown for specialist roles, and map-member-malformed (a warning) for a map member that carries descriptor keys without being an exact descriptor.

The workflow-builder subpath

lua-cli/workflow-builder exports the same builder, helpers, LuaWorkflow and LuaWorkflowBuildError without the runtime client behind them. It exists so a compiled workflow artifact is self-contained; your own files import from 'lua-cli'. Two constants are exported only there:

Script form

A workflow can also be a deterministic JavaScript module at src/workflows/<name>.workflow.script.js, driven by host helpers instead of this builder. Its shape and limits are described in the authoring guide; a workflow’s form is fixed by its first version.

Types

Exported from 'lua-cli': LuaWorkflow, LuaWorkflowBuilder, LuaWorkflowConfig, LuaWorkflowStep, WorkflowStepContext, WorkflowStepResultError, WorkflowArtefactMeta, WorkflowRunTrigger, WorkflowGoalEnvelope, RetryPolicy, StepRef, ContainerArm, AgentStepOptions, AgentToolScope, SpecialistStepOptions, ToolStepOptions, NestedWorkflowOptions, ApprovalOptions, WaitForSignalOptions, ForeachOptions, LoopOptions, TemplateLike, StepPathRef, DotPath, PathValue, WorkflowApproverSpec, WorkflowSuspendTimeoutChain, WorkflowSuspendTimeoutChainMember, WorkflowFourEyes, WorkflowBusinessHours, WorkflowStepWorkspace, WorkflowMergePolicy, WorkflowSuspendOnTimeout, WorkflowOutputVisibility, WorkspaceSpec, WorkflowWorkspaceBackend, WorkflowSpecialistRole, WorkflowJobToolId, WorkflowJobHarness, ReplyChannel, LuaWorkflowBuildError, LuaWorkflowBuildCode, LuaWorkflowBuildWarning, and the wire shapes LuaPredicate, MapDescriptor, LuaMapConfig, TemplateBinding, EnvRefBinding, TypedRef, Literal, PathOrLiteral, JsonSchema, KnowledgeBindingSpec, ArtefactRef, DatasetRef, SerializedWorkflowGraph.

See also